diff --git a/faststack/app.py b/faststack/app.py index 7d81ce8..7beef43 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -9,17 +9,19 @@ import argparse from pathlib import Path from typing import Optional, List, Dict, Any, Tuple, Set -from datetime import date +from datetime import date, datetime import os +import re import shutil import uuid import functools + # Must set before importing PySide6 os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" # Type Aliases for readability DeletePair = Tuple[Optional[Path], Optional[Path]] # (src_path, recycle_bin_path) -DeleteRecord = Tuple[DeletePair, DeletePair] # (jpg_pair, raw_pair) +DeleteRecord = Tuple[DeletePair, DeletePair] # (jpg_pair, raw_pair) import concurrent.futures import threading @@ -35,28 +37,32 @@ Signal, Slot, QMimeData, - Qt, + Qt, QPoint, - QCoreApplication + QCoreApplication, ) from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox from PySide6.QtQml import QQmlApplicationEngine from PIL import Image + Image.MAX_IMAGE_PIXELS = 200_000_000 # 200 megapixels, enough for most photos # ⬇️ these are the ones that went missing from faststack.config import config from faststack.logging_setup import setup_logging -from faststack.models import ImageFile, DecodedImage, EntryMetadata +from faststack.models import ImageFile, DecodedImage from faststack.io.indexer import find_images from faststack.io.sidecar import SidecarManager from faststack.io.watcher import Watcher from faststack.io.helicon import launch_helicon_focus from faststack.io.executable_validator import validate_executable_path -from faststack.imaging.cache import ByteLRUCache, get_decoded_image_size, build_cache_key +from faststack.imaging.cache import ( + ByteLRUCache, + get_decoded_image_size, + build_cache_key, +) from faststack.imaging.prefetch import Prefetcher, clear_icc_caches -from faststack.ui.provider import ImageProvider from faststack.ui.keystrokes import Keybinder -from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS, create_backup_file +from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS from faststack.imaging.metadata import get_exif_data from faststack.thumbnail_view import ( ThumbnailModel, @@ -65,10 +71,14 @@ ThumbnailProvider, PathResolver, ) -import re +from faststack.thumbnail_view.folder_stats import ( + clear_raw_count_cache, + get_file_counts_by_extension, +) import numpy as np from faststack.io.indexer import RAW_EXTENSIONS + def make_hdrop(paths): """ Build a real CF_HDROP (DROPFILES) payload for Windows drag-and-drop. @@ -85,6 +95,7 @@ def make_hdrop(paths): header = struct.pack(" index for O(1) lookup + self._path_to_index: Dict[ + Path, int + ] = {} # Resolved path -> index for O(1) lookup self.current_index: int = 0 self.ui_refresh_generation = 0 self.main_window: Optional[QObject] = None self.engine = engine - self.debug_cache = debug_cache # New debug_cache flag - + self.debug_cache = debug_cache # New debug_cache flag + # Ensure clean shutdown of background threads QCoreApplication.instance().aboutToQuit.connect(self._shutdown_executors) @@ -149,13 +164,13 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.display_height = 0 self.display_generation = 0 self._is_decoding = False - + # Cache Warning State self._last_cache_warning_time = 0 - self._eviction_timestamps = [] # List of eviction timestamps for rate detection + self._eviction_timestamps = [] # List of eviction timestamps for rate detection self.display_ready = False # Track if display size has been reported self.pending_prefetch_index: Optional[int] = None # Deferred prefetch index - + # Edit Source Mode State # "jpeg" (default) or "raw" self.current_edit_source_mode: str = "jpeg" @@ -163,35 +178,42 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: # -- Backend Components -- self.watcher = Watcher(self.image_dir, self.refresh_image_list) self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) - self.image_editor = ImageEditor() # Initialize the editor - self._dialog_open_count = 0 # Track nested dialogs - + self.image_editor = ImageEditor() # Initialize the editor + self._dialog_open_count = 0 # Track nested dialogs + # -- Caching & Prefetching -- - cache_size_gb = config.getfloat('core', 'cache_size_gb', 1.5) + cache_size_gb = config.getfloat("core", "cache_size_gb", 1.5) cache_size_bytes = int(cache_size_gb * 1024**3) self._has_warned_cache_full = False self.image_cache = ByteLRUCache( - max_bytes=cache_size_bytes, + max_bytes=cache_size_bytes, size_of=get_decoded_image_size, - on_evict=self._on_cache_evict + on_evict=self._on_cache_evict, ) - self.image_cache.hits = 0 # Initialize cache hit counter - self.image_cache.misses = 0 # Initialize cache miss counter + self.image_cache.hits = 0 # Initialize cache hit counter + self.image_cache.misses = 0 # Initialize cache miss counter self.prefetcher = Prefetcher( image_files=self.image_files, cache_put=self.image_cache.__setitem__, - prefetch_radius=config.getint('core', 'prefetch_radius', 4), + prefetch_radius=config.getint("core", "prefetch_radius", 4), get_display_info=self.get_display_info, - debug=_debug_mode + debug=_debug_mode, ) - self.last_displayed_image: Optional[DecodedImage] = None # Cache last image to avoid grey squares - self._last_image_lock = threading.Lock() # Protect last_displayed_image from race conditions + self.last_displayed_image: Optional[DecodedImage] = ( + None # Cache last image to avoid grey squares + ) + self._last_image_lock = ( + threading.Lock() + ) # Protect last_displayed_image from race conditions # -- Grid View (Thumbnail) Infrastructure -- self._is_grid_view_active = True # Default to grid view on startup + self._grid_nav_history: list[ + Path + ] = [] # Stack of previous directories for back navigation self._thumbnail_cache = ThumbnailCache( max_bytes=256 * 1024 * 1024, # 256 MB - max_items=5000 + max_items=5000, ) self._path_resolver = PathResolver() self._thumbnail_prefetcher = ThumbnailPrefetcher( @@ -217,32 +239,35 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: # Connect thread-safe thumbnail ready signal to GUI thread handler # The callback is invoked from worker threads, so we use a signal to hop to GUI thread # Explicit QueuedConnection ensures cross-thread safety - self._thumbnailReadySignal.connect(self._on_thumbnail_ready_gui, Qt.QueuedConnection) + self._thumbnailReadySignal.connect( + self._on_thumbnail_ready_gui, Qt.QueuedConnection + ) # -- UI State -- self.ui_state = UIState(self) self.ui_state.theme = self.get_theme() self.ui_state.debugCache = self.debug_cache - self.ui_state.debugMode = _debug_mode # Set debug mode from global + self.ui_state.debugMode = _debug_mode # Set debug mode from global self.keybinder = Keybinder(self) - self.ui_state.debugCache = self.debug_cache # Pass debug_cache state to UI - self.ui_state.isDecoding = False # Initialize decoding indicator - self.is_zoomed = False # Track zoom state for high-res loading logic + self.ui_state.debugCache = self.debug_cache # Pass debug_cache state to UI + self.ui_state.isDecoding = False # Initialize decoding indicator + self.is_zoomed = False # Track zoom state for high-res loading logic # Connect model selection changes to UIState for QML property notification # Must connect to .emit (not the signal itself) for signal-to-signal forwarding - self._thumbnail_model.selectionChanged.connect(self.ui_state.gridSelectedCountChanged.emit) + self._thumbnail_model.selectionChanged.connect( + self.ui_state.gridSelectedCountChanged.emit + ) # -- Stacking State -- self.stack_start_index: Optional[int] = None self.stacks: List[List[int]] = [] - # -- Batch Selection State (for drag-and-drop) -- self.batch_start_index: Optional[int] = None self.batches: List[List[int]] = [] # List of [start, end] ranges - - self._filter_string: str = "" # Default filter + + self._filter_string: str = "" # Default filter self._filter_enabled: bool = False self._metadata_cache = {} @@ -250,10 +275,15 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: with self._last_image_lock: self.last_displayed_image = None self._logged_empty_metadata = False - + # -- Delete/Undo State -- - self.recycle_bin_dir = self.image_dir / "image recycle bin" - self.delete_history: List[DeleteRecord] = [] # [((jpg_src, jpg_bin), (raw_src, raw_bin)), ...] + self.active_recycle_bins: Set[Path] = ( + set() + ) # Track all recycle bins created/used + self.delete_history: List[ + DeleteRecord + ] = [] # [((jpg_src, jpg_bin), (raw_src, raw_bin)), ...] + # Track all undoable actions with timestamps # [(action_type, action_data, timestamp)] self.undo_history: List[Tuple[str, Any, float]] = [] @@ -263,7 +293,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.resize_timer.timeout.connect(self._handle_resize) self.pending_width = None self.pending_height = None - + # Histogram Throttle Timer self.histogram_timer = QTimer(self) self.histogram_timer.setSingleShot(True) @@ -279,14 +309,31 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: # Track if any dialog is open to disable keybindings self._dialog_open = False - - self.auto_level_threshold = config.getfloat('core', 'auto_level_threshold', 0.1) - self.auto_level_strength = config.getfloat('core', 'auto_level_strength', 1.0) - self.auto_level_strength_auto = config.getboolean('core', 'auto_level_strength_auto', False) + + self.auto_level_threshold = config.getfloat("core", "auto_level_threshold", 0.1) + self.auto_level_strength = config.getfloat("core", "auto_level_strength", 1.0) + self.auto_level_strength_auto = config.getboolean( + "core", "auto_level_strength_auto", False + ) # Connect editor open/close signal for memory cleanup self.ui_state.is_editor_open_changed.connect(self._on_editor_open_changed) + def _move_to_recycle(self, src_path: Path) -> Path: + """Moves a file to the recycle bin in the same directory. + + Raises: + OSError/PermissionError if move fails. + """ + recycle_bin = src_path.parent / "image recycle bin" + recycle_bin.mkdir(parents=True, exist_ok=True) + + dst_path = recycle_bin / src_path.name + shutil.move(str(src_path), str(dst_path)) + + self.active_recycle_bins.add(recycle_bin) + return dst_path + @Slot(bool) def _on_editor_open_changed(self, is_open: bool): """Handle necessary setup/cleanup when editor opens or closes.""" @@ -294,12 +341,15 @@ def _on_editor_open_changed(self, is_open: bool): # Warn once if OpenCV is not available (detail sliders will be slower) if not self._opencv_warning_shown: from faststack.imaging.optional_deps import HAS_OPENCV + if not HAS_OPENCV: self._opencv_warning_shown = True - log.warning("OpenCV not available - detail sliders (clarity/texture/sharpness) will be slower") + log.warning( + "OpenCV not available - detail sliders (clarity/texture/sharpness) will be slower" + ) self.update_status_message( "OpenCV not installed - editor performance reduced. Install opencv-python for faster editing.", - timeout=8000 + timeout=8000, ) else: # Cleanup large memory buffers when editor closes @@ -311,7 +361,6 @@ def _on_editor_open_changed(self, is_open: bool): with self._preview_lock: self._last_rendered_preview = None - def is_valid_working_tif(self, path: Path) -> bool: """Checks if a working TIFF path is valid for editing.""" try: @@ -322,7 +371,7 @@ def is_valid_working_tif(self, path: Path) -> bool: def get_active_edit_path(self, index: int) -> Path: """ Determines the correct file path to use for editing/exporting based on current mode. - + Rules: 1. If index invalid, raise IndexError or return None (caller handles). 2. If image is RAW-only (no paired JPEG and path is RAW ext), force "raw" mode functionality. @@ -330,37 +379,38 @@ def get_active_edit_path(self, index: int) -> Path: 3. If mode is "jpeg": return jpg_path (visual/original). 4. If mode is "raw": - Check for valid developed TIFF. If yes, return it. - - If no TIFF, return the RAW path itself (RawTherapee will need to develop it, - or we load it if we support direct RAW - here we likely return raw_path so + - If no TIFF, return the RAW path itself (RawTherapee will need to develop it, + or we load it if we support direct RAW - here we likely return raw_path so load_image_for_editing can decide to develop it). """ if index < 0 or index >= len(self.image_files): raise IndexError("Invalid image index") img = self.image_files[index] - + # Check if we are strictly RAW-only (orphaned RAW or just RAW opened) # ImageFile.path is the main file. ImageFile.raw_pair is the sidecar RAW. # If raw_pair is None but path is a RAW extension, it's RAW-only. is_raw_only = False from faststack.io.indexer import RAW_EXTENSIONS + if img.raw_pair is None and img.path.suffix.lower() in RAW_EXTENSIONS: is_raw_only = True mode = self.current_edit_source_mode if is_raw_only: mode = "raw" - + if mode == "jpeg": return img.path - + # Mode is RAW if img.has_working_tif and self.is_valid_working_tif(img.working_tif_path): return img.working_tif_path - + if img.raw_pair: return img.raw_pair - + # Fallback for RAW-only case where path is the RAW return img.path @@ -375,6 +425,9 @@ def apply_filter(self, filter_string: str): self._filter_string = filter_string self._filter_enabled = True self._apply_filter_to_cached_list() # Fast in-memory filtering + self.display_generation += ( + 1 # Invalidate cache keys to prevent showing stale images + ) self.dataChanged.emit() self.ui_state.filterStringChanged.emit() # Notify UI of filter change @@ -395,14 +448,15 @@ def clear_filter(self): self._filter_enabled = False self._filter_string = "" self._apply_filter_to_cached_list() # Fast in-memory filtering + self.display_generation += ( + 1 # Invalidate cache keys to prevent showing stale images + ) self.dataChanged.emit() self.ui_state.filterStringChanged.emit() # Notify UI of filter change self.current_index = min(self.current_index, max(0, len(self.image_files) - 1)) self.sync_ui_state() self._do_prefetch(self.current_index) - - def get_display_info(self): if self.is_zoomed: return 0, 0, self.display_generation @@ -411,7 +465,9 @@ def get_display_info(self): def on_display_size_changed(self, width: int, height: int): """Debounces display size change events to prevent spamming resizes.""" - log.debug(f"on_display_size_changed called with {width}x{height}. Current: {self.display_width}x{self.display_height}") + log.debug( + f"on_display_size_changed called with {width}x{height}. Current: {self.display_width}x{self.display_height}" + ) if width <= 0 or height <= 0: log.debug("Ignoring invalid resize event") return @@ -423,45 +479,49 @@ def on_display_size_changed(self, width: int, height: int): def _handle_resize(self): """Actual resize handler, called after debounce period.""" - log.info("Display size changed to: %dx%d (physical pixels)", self.pending_width, self.pending_height) + log.info( + "Display size changed to: %dx%d (physical pixels)", + self.pending_width, + self.pending_height, + ) self.display_width = self.pending_width self.display_height = self.pending_height self.display_generation += 1 # Invalidates old entries via cache key - + # Mark display as ready after first size report is_first_resize = not self.display_ready if is_first_resize: self.display_ready = True log.info("Display size now stable, enabling prefetch") - + self.prefetcher.cancel_all() # Cancel stale tasks to avoid wasted work - + # On first resize, execute deferred prefetch; on subsequent resizes, do normal prefetch if is_first_resize and self.pending_prefetch_index is not None: self.prefetcher.update_prefetch(self.pending_prefetch_index) self.pending_prefetch_index = None else: self.prefetcher.update_prefetch(self.current_index) - - self.sync_ui_state() # To refresh the image + + self.sync_ui_state() # To refresh the image @Slot(bool) def set_zoomed(self, zoomed: bool): if self.is_zoomed != zoomed: if _debug_mode: - log.info(f"AppController.set_zoomed: {self.is_zoomed} -> {zoomed}") + log.info(f"AppController.set_zoomed: {self.is_zoomed} -> {zoomed}") self.is_zoomed = zoomed self.is_zoomed_changed.emit(zoomed) log.info("Zoom state changed to: %s", zoomed) self.display_generation += 1 # Invalidates old entries via cache key - + # Invalidate current image to force reload with new resolution logic if self.image_files and self.main_window: # Force QML to reload the image by updating the URL generation self.ui_refresh_generation += 1 self.ui_state.currentImageSourceChanged.emit() - self.main_window.update() # Force repaint - + self.main_window.update() # Force repaint + # -- Zoom Shortcuts -- def zoom_100(self): log.info("Zoom 100% requested") @@ -495,25 +555,54 @@ def eventFilter(self, watched, event) -> bool: # Don't handle key events when a dialog is open if self._dialog_open: return False - + if watched == self.main_window and event.type() == QEvent.Type.KeyPress: # QML handles Crop Enter/Esc keys now. # We defer to QML to avoid double-triggering or focus conflicts. # handled = self.keybinder.handle_key_press(event) ... - + + # Esc closes histogram if visible (priority: before editor/grid handling) + # This works in both grid and loupe view + if event.key() == Qt.Key_Escape and self.ui_state.isHistogramVisible: + self.ui_state.isHistogramVisible = False + return True # Consume event, histogram closed + # When cropping (or editing), let QML handle Enter/Esc and related keys. # Otherwise keybinder can swallow them before QML sees them. - if getattr(self.ui_state, "isCropping", False) or getattr(self.ui_state, "isEditorOpen", False): + if getattr(self.ui_state, "isCropping", False) or getattr( + self.ui_state, "isEditorOpen", False + ): return False + # When in grid view, let QML handle navigation and action keys + if self._is_grid_view_active: + key = event.key() + grid_keys = { + Qt.Key_Left, + Qt.Key_Right, + Qt.Key_Up, + Qt.Key_Down, + Qt.Key_Return, + Qt.Key_Enter, + Qt.Key_Space, + Qt.Key_B, + Qt.Key_Escape, + Qt.Key_Delete, + Qt.Key_Backspace, # Delete handled by QML with cursor context + } + if key in grid_keys: + return False # Let QML handle it + handled = self.keybinder.handle_key_press(event) if handled: return True return super().eventFilter(watched, event) - def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optional[int] = None): + def _do_prefetch( + self, index: int, is_navigation: bool = False, direction: Optional[int] = None + ): """Helper to defer prefetch until display size is stable. - + Args: index: The index to prefetch around is_navigation: True if called from user navigation (arrow keys, etc.) @@ -524,13 +613,15 @@ def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optio if is_navigation and self.resize_timer.isActive(): self.resize_timer.stop() self._handle_resize() - + if not self.display_ready: log.debug("Display not ready, deferring prefetch for index %d", index) self.pending_prefetch_index = index return - self.prefetcher.update_prefetch(index, is_navigation=is_navigation, direction=direction) - + self.prefetcher.update_prefetch( + index, is_navigation=is_navigation, direction=direction + ) + def load(self, skip_thumbnail_refresh: bool = False): """Loads images, sidecar data, and starts services. @@ -541,9 +632,11 @@ def load(self, skip_thumbnail_refresh: bool = False): if not self.image_files: self.current_index = 0 else: - self.current_index = max(0, min(self.sidecar.data.last_index, len(self.image_files) - 1)) - self.stacks = self.sidecar.data.stacks # Load stacks from sidecar - self.dataChanged.emit() # Emit after stacks are loaded + self.current_index = max( + 0, min(self.sidecar.data.last_index, len(self.image_files) - 1) + ) + self.stacks = self.sidecar.data.stacks # Load stacks from sidecar + self.dataChanged.emit() # Emit after stacks are loaded self.watcher.start() self._do_prefetch(self.current_index) @@ -557,31 +650,37 @@ def load(self, skip_thumbnail_refresh: bool = False): def refresh_image_list(self): """Rescans the directory for images from disk and updates cache. - + This does a full disk scan and should only be called when: - Application starts (load()) - Directory watcher detects file changes - User explicitly refreshes - + For filtering, use _apply_filter_to_cached_list() instead. """ + # Clear folder stats cache so subfolder counts are fresh + clear_raw_count_cache() + self._all_images = find_images(self.image_dir) self._apply_filter_to_cached_list() - + + # Refresh thumbnail model if it exists (for external file changes) + if self._thumbnail_model and self._is_grid_view_active: + self._thumbnail_model.refresh() + def _apply_filter_to_cached_list(self): """Applies current filter to cached image list without disk I/O.""" if self._filter_enabled and self._filter_string: needle = self._filter_string.lower() self.image_files = [ - img for img in self._all_images - if needle in img.path.stem.lower() + img for img in self._all_images if needle in img.path.stem.lower() ] else: self.image_files = self._all_images self._rebuild_path_to_index() self.prefetcher.set_image_files(self.image_files) - self._metadata_cache_index = (-1, -1) # Invalidate cache + self._metadata_cache_index = (-1, -1) # Invalidate cache self.ui_state.imageCountChanged.emit() def _rebuild_path_to_index(self): @@ -595,13 +694,15 @@ def _rebuild_path_to_index(self): def get_decoded_image(self, index: int) -> Optional[DecodedImage]: """Retrieves a decoded image, blocking until ready to ensure correct display. - + This blocks the UI thread on cache miss, but that's acceptable for an image viewer where users expect to see the correct image immediately. The prefetcher minimizes cache misses by decoding adjacent images in advance. """ if not self.image_files or index < 0 or index >= len(self.image_files): - log.warning("get_decoded_image called with empty image_files or out of bounds index.") + log.warning( + "get_decoded_image called with empty image_files or out of bounds index." + ) return None # Debug preview condition @@ -609,18 +710,22 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: # Robust path comparison editor_path = self.image_editor.current_filepath file_path = self.image_files[index].path - + match = False if editor_path and file_path: try: match = Path(editor_path).resolve() == Path(file_path).resolve() except (OSError, ValueError): match = str(editor_path) == str(file_path) - + if not match: # Debug log if mismatch - log.debug("Path mismatch in preview. Editor: %s, File: %s", editor_path, file_path) - + log.debug( + "Path mismatch in preview. Editor: %s, File: %s", + editor_path, + file_path, + ) + # Return background-rendered preview if Editor is open OR Cropping is active if match and self.image_editor.original_image: if self._last_rendered_preview: @@ -633,15 +738,15 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: # Check cache if cache_key in self.image_cache: - self.image_cache.hits += 1 # Increment hit counter - self._update_cache_stats() # Update UI with new stats + self.image_cache.hits += 1 # Increment hit counter + self._update_cache_stats() # Update UI with new stats decoded = self.image_cache[cache_key] with self._last_image_lock: self.last_displayed_image = decoded return decoded - - self.image_cache.misses += 1 # Increment miss counter - self._update_cache_stats() # Update UI with new stats + + self.image_cache.misses += 1 # Increment miss counter + self._update_cache_stats() # Update UI with new stats if self.debug_cache: prefix = f"{path_str}::" cached_gens = [ @@ -663,17 +768,23 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: # Cache miss: need to decode synchronously to ensure correct image displays if _debug_mode: decode_start = time.perf_counter() - log.info("Cache miss for index %d (gen: %d). Blocking decode.", index, display_gen) - + log.info( + "Cache miss for index %d (gen: %d). Blocking decode.", + index, + display_gen, + ) + # Show decoding indicator if debug cache is enabled if self.debug_cache: self.ui_state.isDecoding = True # Note: processEvents() caused crashes, so the indicator might not update immediately # QCoreApplication.processEvents() - + try: # Submit with priority=True to cancel pending prefetch tasks and free up workers - future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) + future = self.prefetcher.submit_task( + index, self.prefetcher.generation, priority=True + ) if not future: with self._last_image_lock: return self.last_displayed_image @@ -712,20 +823,24 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: return decoded else: if _debug_mode: - log.debug("Decode finished but cache_key missing (index=%d, key=%s)", index, cache_key) + log.debug( + "Decode finished but cache_key missing (index=%d, key=%s)", + index, + cache_key, + ) with self._last_image_lock: return self.last_displayed_image finally: # Hide decoding indicator if self.debug_cache: self.ui_state.isDecoding = False - + with self._last_image_lock: return self.last_displayed_image def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: """Thread-safe version of get_decoded_image for background workers. - + Does NOT update UI iteration or access QObjects. """ if not self.image_files or index < 0 or index >= len(self.image_files): @@ -733,11 +848,11 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: # Lock to ensure thread safety when reading shared state if necessary (though simple reads are usually safe) # However, get_display_info reads 'self.is_zoomed' which is fine. - # Accessing self.image_files is safe as long as list isn't cleared concurrently, - # which only happens on directory change/refresh on main thread. + # Accessing self.image_files is safe as long as list isn't cleared concurrently, + # which only happens on directory change/refresh on main thread. # Since we are in a worker, there's a small race risk if directory changes *while* we run, # but the worker would likely just fail gracefully or get an old image. - + _, _, display_gen = self.get_display_info() try: image_path = self.image_files[index].path @@ -750,15 +865,17 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: if cache_key in self.image_cache: # We don't update stats/hits here to avoid race conditions on those counters return self.image_cache[cache_key] - + # Cache miss: decode synchronously (in this worker thread) try: # Submit with priority=True - # Note: prefetcher.submit_task logic needs to be thread-safe. + # Note: prefetcher.submit_task logic needs to be thread-safe. # Assuming futures dict access in submit_task handles strict GIL/thread safety or we might need locks there. - # But usually submitting to Executor is thread safe. + # But usually submitting to Executor is thread safe. # The danger is 'self.futures' management in Prefetcher. - future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) + future = self.prefetcher.submit_task( + index, self.prefetcher.generation, priority=True + ) if future: try: result = future.result(timeout=5.0) @@ -766,9 +883,11 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: log.warning(f"Timeout decoding image at index {index} (background)") return None except concurrent.futures.CancelledError: - log.debug(f"Decode cancelled for image at index {index} (background)") + log.debug( + f"Decode cancelled for image at index {index} (background)" + ) return None - + if result: decoded_path, decoded_display_gen = result # Re-verify key @@ -777,7 +896,7 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: return self.image_cache[cache_key] except Exception: log.exception("_get_decoded_image_safe failed for index %d", index) - + return None def sync_ui_state(self): @@ -788,7 +907,7 @@ def sync_ui_state(self): # tell QML that index and image changed self.ui_state.currentIndexChanged.emit() self.ui_state.currentImageSourceChanged.emit() - self.ui_state.highlightStateChanged.emit() # Notify UI of new highlight stats + self.ui_state.highlightStateChanged.emit() # Notify UI of new highlight stats # this is the one your footer needs self.ui_state.metadataChanged.emit() @@ -796,27 +915,22 @@ def sync_ui_state(self): log.debug( "UI State Synced: Index=%d, Count=%d", self.ui_state.currentIndex, - self.ui_state.imageCount + self.ui_state.imageCount, ) log.debug( "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", self.ui_state.currentFilename, self.ui_state.isUploaded, self.ui_state.stackInfoText, - self.ui_state.batchInfoText + self.ui_state.batchInfoText, ) - - # --- Image Editor Integration --- - - - @Slot() def save_edited_image(self): """Saves functionality delegating to ImageEditor. - + Restores "Old" behavior: - Save image - Close Editor @@ -834,7 +948,9 @@ def save_edited_image(self): dev_path = self.image_files[self.current_index].developed_jpg_path try: - result = self.image_editor.save_image(write_developed_jpg=write_sidecar, developed_path=dev_path) + result = self.image_editor.save_image( + write_developed_jpg=write_sidecar, developed_path=dev_path + ) except RuntimeError as e: self.update_status_message(str(e)) return @@ -879,24 +995,22 @@ def save_edited_image(self): pass # Keep current selection if resolution fails self.current_index = new_index - + # 5. Force UI Sync / Prefetch - self.image_cache.clear() # Clear cache to ensure we reload valid image + self.image_cache.clear() # Clear cache to ensure we reload valid image self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - - self.update_status_message(f"Image saved") + + self.update_status_message("Image saved") else: self.update_status_message("Failed to save image") + # --- Actions --- - - - - # --- Actions --- - - def _set_current_index(self, index: int, direction: int = 0, is_navigation: bool = True): + def _set_current_index( + self, index: int, direction: int = 0, is_navigation: bool = True + ): """Centralized method to change current image index and reset state.""" if index < 0 or index >= len(self.image_files): return @@ -906,22 +1020,25 @@ def _set_current_index(self, index: int, direction: int = 0, is_navigation: bool img = self.image_files[index] is_raw_only = False from faststack.io.indexer import RAW_EXTENSIONS, JPG_EXTENSIONS + # Robust RAW-only check: Main path is RAW and it's not a JPEG is_jpeg_main = img.path.suffix.lower() in JPG_EXTENSIONS is_raw_main = img.path.suffix.lower() in RAW_EXTENSIONS is_raw_only = is_raw_main and not is_jpeg_main - + new_mode = "raw" if is_raw_only else "jpeg" if self.current_edit_source_mode != new_mode: self.current_edit_source_mode = new_mode self.editSourceModeChanged.emit(new_mode) - - self.current_index = index # Set index first so signals pick up correct image + + self.current_index = index # Set index first so signals pick up correct image self._reset_crop_settings() - self._do_prefetch(self.current_index, is_navigation=is_navigation, direction=direction) + self._do_prefetch( + self.current_index, is_navigation=is_navigation, direction=direction + ) self.sync_ui_state() - + # Update histogram if visible if self.ui_state.isHistogramVisible: self.update_histogram() @@ -950,10 +1067,12 @@ def jump_to_image(self, index: int): def show_jump_to_image_dialog(self): """Shows the jump to image dialog (called from keybinder).""" - if self.main_window and hasattr(self.main_window, 'show_jump_to_image_dialog'): + if self.main_window and hasattr(self.main_window, "show_jump_to_image_dialog"): self.main_window.show_jump_to_image_dialog() else: - log.warning("Cannot open jump to image dialog: main_window or function not available") + log.warning( + "Cannot open jump to image dialog: main_window or function not available" + ) def show_exif_dialog(self): """Shows the EXIF data dialog.""" @@ -962,13 +1081,15 @@ def show_exif_dialog(self): path = self.image_files[self.current_index].path data = get_exif_data(path) - - if self.main_window and hasattr(self.main_window, 'openExifDialog'): + + if self.main_window and hasattr(self.main_window, "openExifDialog"): # Pass data as QVariantMap (dict) self.main_window.openExifDialog(data) else: - log.warning("Cannot open EXIF dialog: main_window or openExifDialog not available") - + log.warning( + "Cannot open EXIF dialog: main_window or openExifDialog not available" + ) + @Slot() def dialog_opened(self): """Called when any dialog opens to disable global keybindings.""" @@ -977,7 +1098,7 @@ def dialog_opened(self): self._dialog_open = True self.dialogStateChanged.emit(True) log.debug("Dialog opened (count=1), disabling global keybindings") - + @Slot() def dialog_closed(self): """Called when any dialog closes to re-enable global keybindings.""" @@ -992,6 +1113,11 @@ def toggle_grid_view(self): """Toggle between grid view and loupe (single image) view.""" self._set_grid_view_active(not self._is_grid_view_active) + def switch_to_grid_view(self): + """Switch to grid view (from loupe view). Called by Esc key.""" + if not self._is_grid_view_active: + self._set_grid_view_active(True) + def _set_grid_view_active(self, active: bool): """Set grid view active state and handle side effects.""" if self._is_grid_view_active == active: @@ -1004,6 +1130,19 @@ def _set_grid_view_active(self, active: bool): self._thumbnail_model.refresh() # Update path resolver for the current directory self._path_resolver.update_from_model(self._thumbnail_model) + + # Find current loupe image in grid and scroll to it + if self.image_files and 0 <= self.current_index < len(self.image_files): + current_path = self.image_files[self.current_index].path + grid_index = self._thumbnail_model.find_image_index(current_path) + if grid_index >= 0: + # Emit after isGridViewActiveChanged so QML has created the view + from PySide6.QtCore import QTimer + + QTimer.singleShot( + 0, lambda: self.ui_state.gridScrollToIndex.emit(grid_index) + ) + log.info("Switched to grid view") else: log.info("Switched to loupe view") @@ -1016,19 +1155,68 @@ def grid_navigate_to(self, path: str): This updates both the grid view AND the main working directory, so loupe view will show images from the new folder. + + When navigating up above the current base directory (e.g., going to + parent when at the initial launch directory), updates base_directory + to allow continued navigation. """ if not self._is_grid_view_active: return - folder_path = Path(path) + folder_path = Path(path).resolve() if not folder_path.is_dir(): log.warning("Cannot navigate to non-directory: %s", path) return - # Use canonical directory switch (keeps base directory for grid navigation) - self._switch_to_directory(folder_path, update_base_directory=False) + # Push current directory to history before navigating + current_dir = self.image_dir.resolve() + if current_dir != folder_path: + self._grid_nav_history.append(current_dir) + self.ui_state.gridCanGoBackChanged.emit() + + # Check if we're navigating above the current base directory + # This happens when user clicks ".." at the initial launch directory + update_base = False + if self._thumbnail_model: + base_dir = self._thumbnail_model.base_directory + try: + folder_path.relative_to(base_dir) + except ValueError: + # folder_path is outside base_directory - we're going up + update_base = True + log.info( + "Navigating above base directory: %s -> %s", base_dir, folder_path + ) + + # Use canonical directory switch + self._switch_to_directory(folder_path, update_base_directory=update_base) log.info("Grid view navigated to: %s", folder_path) + def grid_go_back(self): + """Navigate back to the previous directory in grid view history.""" + if not self._grid_nav_history: + return + + # Pop the previous directory from history + prev_dir = self._grid_nav_history.pop() + self.ui_state.gridCanGoBackChanged.emit() + + if not prev_dir.is_dir(): + log.warning("Previous directory no longer exists: %s", prev_dir) + return + + # Navigate without adding to history (this is going back, not forward) + update_base = False + if self._thumbnail_model: + base_dir = self._thumbnail_model.base_directory + try: + prev_dir.relative_to(base_dir) + except ValueError: + update_base = True + + self._switch_to_directory(prev_dir, update_base_directory=update_base) + log.info("Grid view went back to: %s", prev_dir) + def grid_open_index(self, index: int): """Open an image from grid view in loupe view.""" entry = self._thumbnail_model.get_entry(index) @@ -1051,7 +1239,9 @@ def grid_open_index(self, index: int): loupe_index = self._path_to_index.get(resolved_path) if loupe_index is None: - log.warning("grid_open_index: image not found in current list: %s", entry.path) + log.warning( + "grid_open_index: image not found in current list: %s", entry.path + ) # Image might be in a different directory - don't switch view return @@ -1063,6 +1253,35 @@ def grid_open_index(self, index: int): log.info("Opened image from grid: %s", entry.path) + def grid_delete_at_cursor(self, cursor_index: int): + """Delete images from grid view - either selection or cursor image. + + If there are selected images, deletes all selected images. + If no selection, deletes the image at the cursor position. + """ + if not self._thumbnail_model: + return + + # Check if there are selections + selected_paths = self._thumbnail_model.get_selected_paths() + if selected_paths: + # Delete all selected images + self._delete_grid_selected_images(selected_paths) + return + + # No selection - delete the cursor image + entry = self._thumbnail_model.get_entry(cursor_index) + if not entry: + log.warning("grid_delete_at_cursor: no entry at index %d", cursor_index) + return + + if entry.is_folder: + self.update_status_message("Cannot delete folders") + return + + # Delete this single image using its path + self._delete_grid_selected_images([entry.path]) + def _on_thumbnail_ready(self, thumbnail_id: str): """Callback when a thumbnail finishes decoding (called from worker thread). @@ -1091,7 +1310,12 @@ def _get_metadata_dict(self, stem: str) -> dict: } except Exception as e: # Broad catch for UI plumbing - don't crash grid view log.debug("Failed to get metadata for %s: %s", stem, e) - return {"stacked": False, "uploaded": False, "edited": False, "restacked": False} + return { + "stacked": False, + "uploaded": False, + "edited": False, + "restacked": False, + } def _invalidate_batch_cache(self): """Clear the batch indices cache. Call after mutating self.batches.""" @@ -1106,7 +1330,10 @@ def _get_batch_indices(self) -> Set[int]: """ # Check if cache is valid (batches haven't changed) cache_key = tuple(tuple(b) for b in self.batches) - if hasattr(self, '_batch_indices_cache_key') and self._batch_indices_cache_key == cache_key: + if ( + hasattr(self, "_batch_indices_cache_key") + and self._batch_indices_cache_key == cache_key + ): return self._batch_indices_cache # Rebuild cache @@ -1122,23 +1349,24 @@ def _get_batch_indices(self) -> Set[int]: def _get_current_loupe_index(self) -> int: """Get current loupe view index (for thumbnail model).""" return self.current_index - + def toggle_uploaded(self): """Toggle uploaded flag for current image.""" if not self.image_files or self.current_index >= len(self.image_files): return - + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) - + meta.uploaded = not meta.uploaded if meta.uploaded: meta.uploaded_date = today else: meta.uploaded_date = None - + self.sidecar.save() self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -1146,23 +1374,24 @@ def toggle_uploaded(self): status = "uploaded" if meta.uploaded else "not uploaded" self.update_status_message(f"Marked as {status}") log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) - + def toggle_edited(self): """Toggle edited flag for current image.""" if not self.image_files or self.current_index >= len(self.image_files): return - + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) - + meta.edited = not meta.edited if meta.edited: meta.edited_date = today else: meta.edited_date = None - + self.sidecar.save() self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -1170,23 +1399,24 @@ def toggle_edited(self): status = "edited" if meta.edited else "not edited" self.update_status_message(f"Marked as {status}") log.info("Toggled edited flag to %s for %s", meta.edited, stem) - + def toggle_restacked(self): """Toggle restacked flag for current image.""" if not self.image_files or self.current_index >= len(self.image_files): return - + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) - + meta.restacked = not meta.restacked if meta.restacked: meta.restacked_date = today else: meta.restacked_date = None - + self.sidecar.save() self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -1194,23 +1424,24 @@ def toggle_restacked(self): status = "restacked" if meta.restacked else "not restacked" self.update_status_message(f"Marked as {status}") log.info("Toggled restacked flag to %s for %s", meta.restacked, stem) - + def toggle_stacked(self): """Toggle stacked flag for current image.""" if not self.image_files or self.current_index >= len(self.image_files): return - + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) - + meta.stacked = not meta.stacked if meta.stacked: meta.stacked_date = today else: meta.stacked_date = None - + self.sidecar.save() self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -1222,22 +1453,24 @@ def toggle_stacked(self): def get_current_metadata(self) -> Dict: if not self.image_files or self.current_index >= len(self.image_files): if not self._logged_empty_metadata: - log.debug("get_current_metadata: image_files is empty or index out of bounds, returning {}.") + log.debug( + "get_current_metadata: image_files is empty or index out of bounds, returning {}." + ) self._logged_empty_metadata = True return {} self._logged_empty_metadata = False - + # Cache hit check cache_key = (self.current_index, self.ui_refresh_generation) if cache_key == self._metadata_cache_index: return self._metadata_cache - + # Compute and cache stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) stack_info = self._get_stack_info(self.current_index) batch_info = self._get_batch_info(self.current_index) - + self._metadata_cache = { "filename": self.image_files[self.current_index].path.name, "stacked": meta.stacked, @@ -1249,7 +1482,7 @@ def get_current_metadata(self) -> Dict: "restacked": meta.restacked, "restacked_date": meta.restacked_date or "", "stack_info_text": stack_info, - "batch_info_text": batch_info + "batch_info_text": batch_info, } self._metadata_cache_index = cache_key return self._metadata_cache @@ -1257,49 +1490,53 @@ def get_current_metadata(self) -> Dict: def begin_new_stack(self): self.stack_start_index = self.current_index log.info("Stack start marked at index %d", self.stack_start_index) - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() # Update UI to show start marker + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Update UI to show start marker self.sync_ui_state() def end_current_stack(self): - log.info("end_current_stack called. stack_start_index: %s", self.stack_start_index) + log.info( + "end_current_stack called. stack_start_index: %s", self.stack_start_index + ) if self.stack_start_index is not None: start = min(self.stack_start_index, self.current_index) end = max(self.stack_start_index, self.current_index) self.stacks.append([start, end]) - self.stacks.sort() # Keep stacks sorted by start index + self.stacks.sort() # Keep stacks sorted by start index self.sidecar.data.stacks = self.stacks self.sidecar.save() log.info("Defined new stack: [%d, %d]", start, end) self.stack_start_index = None - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() # Notify QML of data change - self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Notify QML of data change + self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog self.sync_ui_state() else: log.warning("No stack start marked. Press '[' first.") - + def begin_new_batch(self): """Mark the start of a new batch for drag-and-drop.""" self.batch_start_index = self.current_index log.info("Batch start marked at index %d", self.batch_start_index) - self._metadata_cache_index = (-1, -1) # Invalidate cache + self._metadata_cache_index = (-1, -1) # Invalidate cache self.dataChanged.emit() self.sync_ui_state() self.update_status_message("Batch start marked") - + def end_current_batch(self): """End the current batch and save the range.""" - log.info("end_current_batch called. batch_start_index: %s", self.batch_start_index) + log.info( + "end_current_batch called. batch_start_index: %s", self.batch_start_index + ) if self.batch_start_index is not None: start = min(self.batch_start_index, self.current_index) end = max(self.batch_start_index, self.current_index) self.batches.append([start, end]) - self.batches.sort() # Keep batches sorted by start index + self.batches.sort() # Keep batches sorted by start index self._invalidate_batch_cache() log.info("Defined new batch: [%d, %d]", start, end) self.batch_start_index = None - self._metadata_cache_index = (-1, -1) # Invalidate cache + self._metadata_cache_index = (-1, -1) # Invalidate cache self.dataChanged.emit() self.sync_ui_state() count = end - start + 1 @@ -1307,22 +1544,90 @@ def end_current_batch(self): else: log.warning("No batch start marked. Press '{' first.") self.update_status_message("No batch start marked") - - + + def grid_add_selection_to_batch(self): + """Add grid-selected images to batch.""" + if not self._thumbnail_model: + return + + selected_paths = self._thumbnail_model.get_selected_paths() + if not selected_paths: + self.update_status_message("No images selected in grid.") + return + + # Build path -> index map for the main image list + path_to_index = {} + for i, img in enumerate(self.image_files): + path_to_index[img.path.resolve()] = i + + # Find indices for selected paths + indices_to_add = [] + for path in selected_paths: + resolved = path.resolve() + if resolved in path_to_index: + indices_to_add.append(path_to_index[resolved]) + + if not indices_to_add: + self.update_status_message("Selected images not found in current list.") + return + + # Sort indices and create batch ranges (merge consecutive) + indices_to_add.sort() + added_count = 0 + + for idx in indices_to_add: + # Check if already in a batch + in_batch = False + for start, end in self.batches: + if start <= idx <= end: + in_batch = True + break + + if not in_batch: + # Add as single-item batch (will be merged below) + self.batches.append([idx, idx]) + added_count += 1 + + if added_count > 0: + # Sort and merge overlapping/adjacent batches + self.batches.sort() + merged_batches = [self.batches[0]] if self.batches else [] + for i in range(1, len(self.batches)): + last_start, last_end = merged_batches[-1] + current_start, current_end = self.batches[i] + if current_start <= last_end + 1: + merged_batches[-1] = [last_start, max(last_end, current_end)] + else: + merged_batches.append([current_start, current_end]) + self.batches = merged_batches + + self._invalidate_batch_cache() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + # Refresh grid to show batch badges + self._thumbnail_model.refresh() + + self.update_status_message(f"Added {added_count} image(s) to batch") + log.info("Added %d image(s) to batch from grid selection", added_count) + else: + self.update_status_message("All selected images already in batch.") + def remove_from_batch_or_stack(self): """Remove current image from any batch or stack it's in.""" if not self.image_files or self.current_index >= len(self.image_files): return - + removed = False - + # Check and remove from batches new_batches = [] batch_modified = False for start, end in self.batches: if not batch_modified and start <= self.current_index <= end: # This is the batch to modify. - + # Single image batch - remove entirely by not adding anything. if start == end: pass @@ -1336,14 +1641,19 @@ def remove_from_batch_or_stack(self): else: new_batches.append([start, self.current_index - 1]) new_batches.append([self.current_index + 1, end]) - - log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) - self.update_status_message(f"Removed from batch") + + log.info( + "Removed index %d from batch [%d, %d]", + self.current_index, + start, + end, + ) + self.update_status_message("Removed from batch") removed = True batch_modified = True else: new_batches.append([start, end]) - + if batch_modified: self.batches = new_batches self._invalidate_batch_cache() @@ -1356,7 +1666,7 @@ def remove_from_batch_or_stack(self): for start, end in self.stacks: if not stack_modified and start <= self.current_index <= end: # This is the stack to modify. - + # Single image stack - remove entirely. if start == end: pass @@ -1370,18 +1680,23 @@ def remove_from_batch_or_stack(self): else: new_stacks.append([start, self.current_index - 1]) new_stacks.append([self.current_index + 1, end]) - - log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) - self.update_status_message(f"Removed from stack") + + log.info( + "Removed index %d from stack [%d, %d]", + self.current_index, + start, + end, + ) + self.update_status_message("Removed from stack") removed = True stack_modified = True else: new_stacks.append([start, end]) - + if stack_modified: self.stacks = new_stacks self.sidecar.data.stacks = self.stacks - self.sidecar.save() + self.sidecar.save() if removed: self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -1396,14 +1711,14 @@ def toggle_batch_membership(self): return index_to_toggle = self.current_index - + # Check if the image is already in a batch in_batch = False for start, end in self.batches: if start <= index_to_toggle <= end: in_batch = True break - + new_batches = [] if in_batch: # Remove from batch @@ -1425,7 +1740,10 @@ def toggle_batch_membership(self): if not self.batches: self.batches.append([index_to_toggle, index_to_toggle]) self.update_status_message("Created new batch with current image.") - log.info("No existing batches. Created new batch for index %d.", index_to_toggle) + log.info( + "No existing batches. Created new batch for index %d.", + index_to_toggle, + ) else: # Check if adjacent to any existing batch merged = False @@ -1440,11 +1758,11 @@ def toggle_batch_membership(self): self.batches[i] = [start, index_to_toggle] merged = True break - + if not merged: # Not adjacent to any batch, create new one self.batches.append([index_to_toggle, index_to_toggle]) - + # Sort and merge any overlapping batches self.batches.sort() merged_batches = [self.batches[0]] if self.batches else [] @@ -1456,7 +1774,7 @@ def toggle_batch_membership(self): else: merged_batches.append([current_start, current_end]) self.batches = merged_batches - + self.update_status_message("Added image to batch") log.info("Added index %d to batch.", index_to_toggle) @@ -1471,14 +1789,14 @@ def toggle_stack_membership(self): return index_to_toggle = self.current_index - + # Check if the image is already in a stack stack_to_modify_idx = -1 for i, (start, end) in enumerate(self.stacks): if start <= index_to_toggle <= end: stack_to_modify_idx = i break - + if stack_to_modify_idx != -1: # --- Remove from existing stack --- new_stacks = [] @@ -1494,17 +1812,24 @@ def toggle_stack_membership(self): new_stacks.append([start, end]) self.stacks = new_stacks self.update_status_message("Removed image from stack") - log.info("Removed index %d from stack #%d.", index_to_toggle, stack_to_modify_idx + 1) + log.info( + "Removed index %d from stack #%d.", + index_to_toggle, + stack_to_modify_idx + 1, + ) else: # --- Add to nearest stack --- if not self.stacks: self.stacks.append([index_to_toggle, index_to_toggle]) self.update_status_message("Created new stack with current image.") - log.info("No existing stacks. Created new stack for index %d.", index_to_toggle) + log.info( + "No existing stacks. Created new stack for index %d.", + index_to_toggle, + ) else: # Find closest stack - dist_backward = float('inf') + dist_backward = float("inf") stack_idx_backward = -1 for i in range(index_to_toggle - 1, -1, -1): for j, (start, end) in enumerate(self.stacks): @@ -1515,7 +1840,7 @@ def toggle_stack_membership(self): if stack_idx_backward != -1: break - dist_forward = float('inf') + dist_forward = float("inf") stack_idx_forward = -1 for i in range(index_to_toggle + 1, len(self.image_files)): for j, (start, end) in enumerate(self.stacks): @@ -1525,12 +1850,15 @@ def toggle_stack_membership(self): break if stack_idx_forward != -1: break - + if stack_idx_backward == -1 and stack_idx_forward == -1: # This case should not be reached if `if not self.stacks` handles it. self.stacks.append([index_to_toggle, index_to_toggle]) self.update_status_message("Created new stack with current image.") - log.info("No stacks found nearby. Created new stack for index %d.", index_to_toggle) + log.info( + "No stacks found nearby. Created new stack for index %d.", + index_to_toggle, + ) else: if dist_backward <= dist_forward: stack_to_join_idx = stack_idx_backward @@ -1538,8 +1866,11 @@ def toggle_stack_membership(self): stack_to_join_idx = stack_idx_forward start, end = self.stacks[stack_to_join_idx] - self.stacks[stack_to_join_idx] = [min(start, index_to_toggle), max(end, index_to_toggle)] - + self.stacks[stack_to_join_idx] = [ + min(start, index_to_toggle), + max(end, index_to_toggle), + ] + # Merge overlapping stacks self.stacks.sort() merged_stacks = [self.stacks[0]] if self.stacks else [] @@ -1559,8 +1890,14 @@ def toggle_stack_membership(self): new_stack_idx = i break - self.update_status_message(f"Added image to Stack #{new_stack_idx + 1}") - log.info("Added index %d to stack #%d.", index_to_toggle, new_stack_idx + 1) + self.update_status_message( + f"Added image to Stack #{new_stack_idx + 1}" + ) + log.info( + "Added index %d to stack #%d.", + index_to_toggle, + new_stack_idx + 1, + ) self.sidecar.data.stacks = self.stacks self.sidecar.save() @@ -1578,17 +1915,19 @@ def _reset_crop_settings(self): # Also clear any editor-side crop box in case it's not fully synced yet self.image_editor.set_crop_box((0, 0, 1000, 1000)) # Reset rotation and straighten angle - self.image_editor.set_edit_param('rotation', 0) - self.image_editor.set_edit_param('straighten_angle', 0.0) + self.image_editor.set_edit_param("rotation", 0) + self.image_editor.set_edit_param("straighten_angle", 0.0) # Also update UI state for rotation values if they are exposed - if hasattr(self.ui_state, 'rotation'): + if hasattr(self.ui_state, "rotation"): self.ui_state.rotation = 0 - if hasattr(self.ui_state, 'cropRotation'): # This is used by Components.qml for the overlay + if hasattr( + self.ui_state, "cropRotation" + ): # This is used by Components.qml for the overlay self.ui_state.cropRotation = 0.0 - + # Also reset the straighten angle in current_edits since it affects rotation logic - if 'straighten_angle' in self.image_editor.current_edits: - self.image_editor.current_edits['straighten_angle'] = 0.0 + if "straighten_angle" in self.image_editor.current_edits: + self.image_editor.current_edits["straighten_angle"] = 0.0 def launch_helicon(self): """Launches Helicon with selected files (RAW preferred, JPG fallback) or stacks.""" @@ -1601,16 +1940,18 @@ def launch_helicon(self): if idx < len(self.image_files): img_file = self.image_files[idx] # Use RAW if available, otherwise use JPG - file_to_use = img_file.raw_pair if img_file.raw_pair else img_file.path + file_to_use = ( + img_file.raw_pair if img_file.raw_pair else img_file.path + ) files_to_process.append(file_to_use) - + if files_to_process: success = self._launch_helicon_with_files(files_to_process) if success: any_success = True else: log.warning("No valid files found for stack [%d, %d].", start, end) - + # Only clear stacks if at least one launch succeeded if any_success: self.clear_all_stacks() @@ -1623,7 +1964,7 @@ def launch_helicon(self): def _launch_helicon_with_files(self, files: List[Path]) -> bool: """Helper to launch Helicon with a specific list of files (RAW or JPG). - + Returns: True if Helicon was successfully launched, False otherwise. """ @@ -1647,8 +1988,8 @@ def _launch_helicon_with_files(self, files: List[Path]) -> bool: meta.stacked_date = today break self.sidecar.save() - self._metadata_cache_index = (-1, -1) # Invalidate cache - + self._metadata_cache_index = (-1, -1) # Invalidate cache + return success def _delete_temp_file(self, tmp_path: Path): @@ -1665,10 +2006,10 @@ def clear_all_stacks(self): self.stacks = [] self.stack_start_index = None # Do NOT clear batches here - + self.sidecar.data.stacks = self.stacks self.sidecar.save() - + self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.ui_state.stackSummaryChanged.emit() @@ -1688,24 +2029,24 @@ def clear_all_batches(self): self.update_status_message("All batches cleared") def get_helicon_path(self): - return config.get('helicon', 'exe') + return config.get("helicon", "exe") def set_helicon_path(self, path): - config.set('helicon', 'exe', path) + config.set("helicon", "exe", path) config.save() def get_photoshop_path(self): - return config.get('photoshop', 'exe') + return config.get("photoshop", "exe") def set_photoshop_path(self, path): - config.set('photoshop', 'exe', path) + config.set("photoshop", "exe", path) config.save() def get_rawtherapee_path(self): - return config.get('rawtherapee', 'exe') + return config.get("rawtherapee", "exe") def set_rawtherapee_path(self, path): - config.set('rawtherapee', 'exe', path) + config.set("rawtherapee", "exe", path) config.save() def open_file_dialog(self): @@ -1720,8 +2061,8 @@ def check_path_exists(self, path): return os.path.exists(path) def get_cache_size(self): - return config.getfloat('core', 'cache_size_gb') - + return config.getfloat("core", "cache_size_gb") + def get_cache_usage_gb(self): """Returns current cache usage in GB.""" return self.image_cache.currsize / (1024**3) @@ -1729,48 +2070,51 @@ def get_cache_usage_gb(self): def set_cache_size(self, size): """Update cache size at runtime and persist to config.""" size = max(0.5, min(size, 16.0)) # enforce sane bounds - config.set('core', 'cache_size_gb', size) + config.set("core", "cache_size_gb", size) config.save() - + old_max_bytes = self.image_cache.max_bytes new_max_bytes = int(size * 1024**3) if old_max_bytes == new_max_bytes: return - - log.info("Resizing decoded image cache from %.2f GB to %.2f GB", - old_max_bytes / (1024**3), size) + + log.info( + "Resizing decoded image cache from %.2f GB to %.2f GB", + old_max_bytes / (1024**3), + size, + ) self.image_cache.max_bytes = new_max_bytes - + # If the new size is smaller than current usage, evict until under limit while self.image_cache.currsize > new_max_bytes and len(self.image_cache) > 0: try: self.image_cache.popitem() except KeyError: break - + # Allow future warnings after expanding the cache if new_max_bytes > old_max_bytes: self._has_warned_cache_full = False def get_prefetch_radius(self): - return config.getint('core', 'prefetch_radius') + return config.getint("core", "prefetch_radius") def set_prefetch_radius(self, radius): - config.set('core', 'prefetch_radius', radius) + config.set("core", "prefetch_radius", radius) config.save() self.prefetcher.prefetch_radius = radius self.prefetcher.update_prefetch(self.current_index) def get_theme(self): - return 0 if config.get('core', 'theme') == 'dark' else 1 + return 0 if config.get("core", "theme") == "dark" else 1 def set_theme(self, theme_index): # update Python-side state self.ui_state.theme = theme_index # persist it - theme = 'dark' if theme_index == 0 else 'light' - config.set('core', 'theme', theme) + theme = "dark" if theme_index == 0 else "light" + config.set("core", "theme", theme) config.save() # tell QML it changed (once is enough) @@ -1779,62 +2123,62 @@ def set_theme(self, theme_index): @Slot(result=str) def get_color_mode(self): """Returns current color management mode: 'none', 'saturation', or 'icc'.""" - return config.get('color', 'mode', fallback='none') + return config.get("color", "mode", fallback="none") @Slot(str) def set_color_mode(self, mode: str): """Sets color management mode and clears cache to force re-decode.""" mode = mode.lower() - if mode not in ['none', 'saturation', 'icc']: + if mode not in ["none", "saturation", "icc"]: log.error("Invalid color mode: %s", mode) return - + log.info("Setting color mode to: %s", mode) - config.set('color', 'mode', mode) + config.set("color", "mode", mode) config.save() - + # Clear ICC caches when color mode changes clear_icc_caches() - + # Clear cache and restart prefetcher to apply new color mode self.image_cache.clear() self.prefetcher.cancel_all() self.display_generation += 1 self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - + # Notify QML that color mode changed self.ui_state.colorModeChanged.emit() - + # Update status message mode_names = { - 'none': 'Original Colors', - 'saturation': 'Saturation Compensation', - 'icc': 'Full ICC Profile' + "none": "Original Colors", + "saturation": "Saturation Compensation", + "icc": "Full ICC Profile", } self.update_status_message(f"Color mode: {mode_names.get(mode, mode)}") @Slot(result=float) def get_saturation_factor(self): """Returns current saturation factor (0.0-1.0).""" - return config.getfloat('color', 'saturation_factor', fallback=0.85) + return config.getfloat("color", "saturation_factor", fallback=0.85) @Slot(float) def set_saturation_factor(self, factor: float): """Sets saturation factor and refreshes images.""" factor = max(0.0, min(1.0, factor)) # Clamp to 0-1 log.info("Setting saturation factor to: %.2f", factor) - config.set('color', 'saturation_factor', str(factor)) + config.set("color", "saturation_factor", str(factor)) config.save() - + # Only refresh if in saturation mode - if self.get_color_mode() == 'saturation': + if self.get_color_mode() == "saturation": self.image_cache.clear() self.prefetcher.cancel_all() self.display_generation += 1 self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - + # Notify QML self.ui_state.saturationFactorChanged.emit() @@ -1855,9 +2199,9 @@ def get_awb_strength(self): def set_awb_strength(self, value): config.set("awb", "strength", value) config.save() - + # Refresh if AWB was recently applied - if self.get_color_mode() in ['saturation', 'icc']: + if self.get_color_mode() in ["saturation", "icc"]: self.image_cache.clear() self.prefetcher.cancel_all() self.display_generation += 1 @@ -1870,22 +2214,22 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): """Sets the straighten angle for the image editor and updates current view.""" if not (self.ui_state.isEditorOpen or self.ui_state.isCropping): return - + # Optimization: Assume image is loaded by toggle_crop_mode or open_editor. # Avoid disk I/O here to prevent stutter during drag. if not self.image_editor.original_image: - return + return # log.info(f"AppController.set_straighten_angle: {angle}, AR: {target_aspect_ratio}") - + # Update Aspect Ratio Compensation for Crop Box - # If we have a target aspect ratio, we need to adjust the normalized crop box + # If we have a target aspect ratio, we need to adjust the normalized crop box # because the underlying canvas aspect ratio changes with rotation (expand=True). if target_aspect_ratio > 0 and self.ui_state.currentCropBox: left, top, right, bottom = self.ui_state.currentCropBox w_norm = right - left h_norm = bottom - top - + if w_norm > 0 and h_norm > 0: # Calculate new canvas dimensions # PIL expand=True logic: @@ -1895,51 +2239,51 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): # New dimensions new_w = abs(im_w * math.cos(rad)) + abs(im_h * math.sin(rad)) new_h = abs(im_w * math.sin(rad)) + abs(im_h * math.cos(rad)) - + if new_w > 0 and new_h > 0: canvas_aspect = new_w / new_h - + # We want PixelAspect = (w_norm * new_w/1000) / (h_norm * new_h/1000) = target_aspect # (w_norm / h_norm) * (new_w / new_h) = target_aspect # w_norm / h_norm = target_aspect / canvas_aspect - + target_norm_ratio = target_aspect_ratio / canvas_aspect - + # Adjust dimensions to match target_norm_ratio # Simple: Preserve Width, adjust Height. - + new_h_norm = w_norm / target_norm_ratio - + # If new height exceeds bounds (1000), constrain and adjust width instead if new_h_norm > 1000: - new_h_norm = 1000 - w_norm = new_h_norm * target_norm_ratio + new_h_norm = 1000 + w_norm = new_h_norm * target_norm_ratio # Recenter height cy = (top + bottom) / 2 top = cy - new_h_norm / 2 bottom = cy + new_h_norm / 2 - + # Clamp vertical - if top < 0: - bottom -= top # shift down + if top < 0: + bottom -= top # shift down top = 0 if bottom > 1000: - top -= (bottom - 1000) # shift up + top -= bottom - 1000 # shift up bottom = 1000 if top < 0: - top = 0 # double clamp - + top = 0 # double clamp + # Recenter width (if changed) cx = (left + right) / 2 left = cx - w_norm / 2 right = cx + w_norm / 2 - + # Clamp horizontal if left < 0: right -= left left = 0 if right > 1000: - left -= (right - 1000) + left -= right - 1000 right = 1000 if left < 0: left = 0 @@ -1952,11 +2296,11 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): # QML rotation is CW-positive. # ImageEditor expects CW-positive and handles the inversion for PIL internally. self.image_editor.set_edit_param("straighten_angle", angle) - + # Trigger refresh. Since we are editing, we are viewing the preview. # Incrementing display generation invalidates cache, but for preview it just ensures freshness if logic depends on it. # Crucially, sync_ui_state emits currentImageSourceChanged, forcing QML to reload. - # self.display_generation += 1 + # self.display_generation += 1 # self.sync_ui_state() # DISABLE TO PREVENT FLASHING - QML handles preview live @Slot(result=int) @@ -2012,30 +2356,32 @@ def get_awb_rgb_upper_bound(self): def set_awb_rgb_upper_bound(self, value): config.set("awb", "rgb_upper_bound", value) config.save() - + def get_default_directory(self): - return config.get('core', 'default_directory') + return config.get("core", "default_directory") def set_default_directory(self, path): - config.set('core', 'default_directory', path) + config.set("core", "default_directory", path) config.save() def get_optimize_for(self): - return config.get('core', 'optimize_for', fallback='speed') - + return config.get("core", "optimize_for", fallback="speed") + def set_optimize_for(self, optimize_for): - old_value = config.get('core', 'optimize_for', fallback='speed') - config.set('core', 'optimize_for', optimize_for) + old_value = config.get("core", "optimize_for", fallback="speed") + config.set("core", "optimize_for", optimize_for) config.save() - + # If the setting changed, clear cache and redraw current image if old_value != optimize_for: - log.info(f"Optimize for changed from {old_value} to {optimize_for}, clearing cache and redrawing") + log.info( + f"Optimize for changed from {old_value} to {optimize_for}, clearing cache and redrawing" + ) self.image_cache.clear() # Force redraw of current image if self.current_index >= 0 and self.current_index < len(self.image_files): self.ui_state.currentImageSourceChanged.emit() - + @Slot(result=float) def get_auto_level_clipping_threshold(self): return self.auto_level_threshold @@ -2046,7 +2392,7 @@ def set_auto_level_clipping_threshold(self, value): value = max(0.0, min(1.0, value)) self.auto_level_threshold = value # Store as formatted string to avoid scientific notation weirdness or precision issues - config.set('core', 'auto_level_threshold', f"{value:.6g}") + config.set("core", "auto_level_threshold", f"{value:.6g}") config.save() @Slot(result=float) @@ -2058,7 +2404,7 @@ def set_auto_level_strength(self, value): # Clamp to 0-1 range value = max(0.0, min(1.0, value)) self.auto_level_strength = value - config.set('core', 'auto_level_strength', f"{value:.6g}") + config.set("core", "auto_level_strength", f"{value:.6g}") config.save() @Slot(result=bool) @@ -2069,9 +2415,9 @@ def get_auto_level_strength_auto(self): def set_auto_level_strength_auto(self, value): self.auto_level_strength_auto = value # Store as canonical lowercase string - config.set('core', 'auto_level_strength_auto', "true" if value else "false") + config.set("core", "auto_level_strength_auto", "true" if value else "false") config.save() - + def open_directory_dialog(self): dialog = QFileDialog() dialog.setFileMode(QFileDialog.FileMode.Directory) @@ -2079,7 +2425,9 @@ def open_directory_dialog(self): return dialog.selectedFiles()[0] return "" - def _switch_to_directory(self, folder_path: Path, update_base_directory: bool = True): + def _switch_to_directory( + self, folder_path: Path, update_base_directory: bool = True + ): """Canonical directory switch - used by both open_folder() and grid_navigate_to(). Args: @@ -2098,11 +2446,19 @@ def _switch_to_directory(self, folder_path: Path, update_base_directory: bool = # Reinitialize directory-bound components self.watcher = Watcher(self.image_dir, self.refresh_image_list) self.sidecar = SidecarManager(self.image_dir, self.watcher, debug=_debug_mode) - self.recycle_bin_dir = self.image_dir / "image recycle bin" + + # Only update recycle bin when switching base directories (not subfolder navigation) + # This ensures all deleted files go to the same recycle bin + if update_base_directory: + self.recycle_bin_dir = self.image_dir / "image recycle bin" + # Only clear history when switching base directories + self.delete_history = [] + self.undo_history = [] + # Clear grid navigation history (don't allow "back" to previous base folder) + self._grid_nav_history.clear() + self.ui_state.gridCanGoBackChanged.emit() # Clear directory-specific state - self.delete_history = [] - self.undo_history = [] self.stacks = [] self.batches = [] self.batch_start_index = None @@ -2133,12 +2489,16 @@ def _switch_to_directory(self, folder_path: Path, update_base_directory: bool = if self._thumbnail_model: if update_base_directory: self._thumbnail_model.set_directories(self.image_dir, self.image_dir) + self._thumbnail_model.refresh() # set_directories doesn't refresh else: + # navigate_to() already calls refresh() internally self._thumbnail_model.navigate_to(self.image_dir) - self._thumbnail_model.refresh() self._path_resolver.update_from_model(self._thumbnail_model) self.ui_state.gridDirectoryChanged.emit(str(self.image_dir)) + # Notify that the current directory changed (for window title) + self.ui_state.currentDirectoryChanged.emit() + # Load images from new directory (thumbnail model already refreshed above) self.load(skip_thumbnail_refresh=True) @@ -2149,7 +2509,6 @@ def open_folder(self): if path: self._switch_to_directory(Path(path), update_base_directory=True) - def preload_all_images(self): if self.ui_state.isPreloading: log.info("Preloading is already in progress.") @@ -2174,27 +2533,27 @@ def preload_all_images(self): images_to_preload = [] already_cached_count = 0 _, _, display_gen = self.get_display_info() - + # We want to load images furthest from the current index FIRST, # and images closest to the current index LAST. # This ensures that the images the user is currently looking at (and their neighbors) # are the most recently added to the LRU cache, so they won't be evicted. - + # Calculate distance for all images # (index, distance_from_current) all_images_with_dist = [] for i in range(total_images): dist = abs(i - self.current_index) all_images_with_dist.append((i, dist)) - + # Sort by distance descending (furthest first) all_images_with_dist.sort(key=lambda x: x[1], reverse=True) - + # Determine which images are "nearby" (e.g. within prefetch radius * 2) # We will FORCE these to be re-cached even if they are already in cache, # to ensure they are moved to the front of the LRU queue. nearby_radius = self.prefetcher.prefetch_radius * 2 - + for i, dist in all_images_with_dist: if i >= len(self.image_files): continue @@ -2202,25 +2561,27 @@ def preload_all_images(self): cache_key = build_cache_key(image_path, display_gen) is_cached = cache_key in self.image_cache is_nearby = dist <= nearby_radius - + if is_cached and not is_nearby: already_cached_count += 1 else: # Add to preload list if it's not cached OR if it's nearby (to refresh LRU) images_to_preload.append(i) - - log.info(f"Found {already_cached_count} cached images (skipping). Preloading {len(images_to_preload)} images (including nearby refreshes).") + + log.info( + f"Found {already_cached_count} cached images (skipping). Preloading {len(images_to_preload)} images (including nearby refreshes)." + ) if not images_to_preload: log.info("All images are already cached.") self._update_preload_progress(100) self._finish_preloading() return - + # --- Setup progress tracking --- # `completed` starts at the number of images already cached (that we are skipping). completed = already_cached_count - + # Update initial progress initial_progress = int((completed / total_images) * 100) self._update_preload_progress(initial_progress) @@ -2233,7 +2594,7 @@ def _on_done(_future): # Check if all images (including cached ones) are accounted for if completed == total_images: self.reporter.finished.emit() - + # --- Submit tasks --- # images_to_preload is already sorted furthest -> nearest for i in images_to_preload: @@ -2242,7 +2603,7 @@ def _on_done(_future): # ByteLRUCache (cachetools) updates LRU on access (get/set), so just overwriting is fine. # But we need to make sure we don't skip the task in prefetcher if it thinks it's already done. # The prefetcher checks self.futures, but we are submitting new ones. - + future = self.prefetcher.submit_task(i, self.prefetcher.generation) if future: future.add_done_callback(_on_done) @@ -2261,33 +2622,40 @@ def get_batch_count_for_current_image(self) -> int: """Get the count of images in the batch that contains the current image.""" if not self.image_files: return 0 - + # Check if current image is in any batch for start, end in self.batches: if start <= self.current_index <= end: # Calculate total count across all batches total_count = sum(end - start + 1 for start, end in self.batches) return total_count - + return 0 @Slot() def delete_current_image(self): """Moves current JPG and RAW to recycle bin. Shows dialog if multiple images in batch.""" + # Check if in grid view with selections - delete grid selection instead + if self._is_grid_view_active and self._thumbnail_model: + selected_paths = self._thumbnail_model.get_selected_paths() + if selected_paths: + self._delete_grid_selected_images(selected_paths) + return + if not self.image_files: self.update_status_message("No image to delete.") return - + # Check if current image is in a batch with multiple images batch_count = self.get_batch_count_for_current_image() - + if batch_count > 1: # Show dialog asking what to delete - if hasattr(self, 'main_window') and self.main_window: + if hasattr(self, "main_window") and self.main_window: # Set batch count in dialog and open it self.main_window.show_delete_batch_dialog(batch_count) return - + # Single image deletion - proceed normally self._delete_single_image(self.current_index) @@ -2295,26 +2663,30 @@ def _move_to_recycle(self, src: Path) -> Optional[Path]: """Moves a file to the recycle bin safely, handling collisions and cross-device moves.""" if not src.exists() or not src.is_file(): return None - + + # Create recycle bin in the same folder as the source file + recycle_bin = src.parent / "image recycle bin" + # Ensure recycle bin exists try: - self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) + recycle_bin.mkdir(parents=True, exist_ok=True) + self.active_recycle_bins.add(recycle_bin) except OSError as e: log.error("Failed to create recycle bin: %s", e) return None - dest = self.recycle_bin_dir / src.name - + dest = recycle_bin / src.name + # Handle collisions with timestamp loop if dest.exists(): timestamp = int(time.time()) base_name = f"{src.stem}.{timestamp}" - dest = self.recycle_bin_dir / f"{base_name}{src.suffix}" + dest = recycle_bin / f"{base_name}{src.suffix}" counter = 1 while dest.exists(): - dest = self.recycle_bin_dir / f"{base_name}_{counter}{src.suffix}" + dest = recycle_bin / f"{base_name}_{counter}{src.suffix}" counter += 1 - + try: shutil.move(str(src), str(dest)) log.info("Moved %s to recycle bin: %s", src.name, dest.name) @@ -2323,38 +2695,125 @@ def _move_to_recycle(self, src: Path) -> Optional[Path]: log.error("Failed to recycle %s: %s", src.name, e) return None + def _ensure_recycle_bin_dir(self) -> bool: + """Try to create the recycle bin directory. + + Returns: + True if recycle bin exists or was created successfully. + False if creation failed (e.g., permission denied). + """ + from faststack.io.deletion import ensure_recycle_bin_dir + + return ensure_recycle_bin_dir(self.recycle_bin_dir) + + def _confirm_permanent_delete(self, image_file, reason: str = "") -> bool: + """Show a confirmation dialog for permanent deletion of a single image. + + Args: + image_file: The ImageFile to delete permanently. + reason: Reason for permanent deletion (e.g., "Recycle bin unavailable"). + + Returns: + True if user confirms deletion, False if cancelled. + """ + from faststack.io.deletion import confirm_permanent_delete + + return confirm_permanent_delete(image_file, reason) + + def _confirm_batch_permanent_delete(self, images: list, reason: str = "") -> bool: + """Show a confirmation dialog for permanent deletion of multiple images. + + Args: + images: List of ImageFile objects to delete permanently. + reason: Reason for permanent deletion. + + Returns: + True if user confirms deletion, False if cancelled. + """ + from faststack.io.deletion import confirm_batch_permanent_delete + + return confirm_batch_permanent_delete(images, reason) + + def _permanently_delete_image_files(self, image_file) -> bool: + """Permanently delete an image and its RAW pair from disk. + + This does NOT add to undo history since deletion is permanent. + + Args: + image_file: The ImageFile to delete. + + Returns: + True if at least one file was deleted, False otherwise. + """ + from faststack.io.deletion import permanently_delete_image_files + + return permanently_delete_image_files(image_file) + def _delete_single_image(self, index: int): """Internal method to delete a single image by index.""" if not self.image_files or index < 0 or index >= len(self.image_files): self.update_status_message("No image to delete.") return - + previous_index = self.current_index image_file = self.image_files[index] jpg_path = image_file.path raw_path = image_file.raw_pair - - # Move files to recycle bin - recycled_jpg = self._move_to_recycle(jpg_path) - recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None - - # Add to delete history if anything was moved - if recycled_jpg or recycled_raw: - import time - timestamp = time.time() - # Store tuple of (src, bin_path) for each file - # Format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) - record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) - - self.delete_history.append(record) - self.undo_history.append(("delete", record, timestamp)) - - if not recycled_jpg and not recycled_raw: - self.update_status_message("Delete failed") - return - + + # Try to ensure recycle bin is available + recycle_bin_available = self._ensure_recycle_bin_dir() + + if recycle_bin_available: + # Normal path: move to recycle bin + recycled_jpg = self._move_to_recycle(jpg_path) + recycled_raw = ( + self._move_to_recycle(raw_path) + if (raw_path and raw_path.exists()) + else None + ) + + # Add to delete history if anything was moved + if recycled_jpg or recycled_raw: + import time + + timestamp = time.time() + # Store tuple of (src, bin_path) for each file + # Format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) + record = ((jpg_path, recycled_jpg), (raw_path, recycled_raw)) + + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + status_msg = f"Deleted {jpg_path.name}" + else: + self.update_status_message("Delete failed") + return + else: + # Fallback: permanent delete with confirmation + if not self._confirm_permanent_delete( + image_file, + reason="Recycle bin could not be created due to permissions.", + ): + self.update_status_message("Deletion cancelled") + return + + if self._permanently_delete_image_files(image_file): + # Do NOT add to undo_history - permanent deletion is not undoable + status_msg = ( + f"Permanently deleted {jpg_path.name} (recycle bin unavailable)" + ) + else: + self.update_status_message("Delete failed") + return + + # Clear folder stats cache so recycle bin count updates + clear_raw_count_cache() + # Refresh image list and move to next image self.refresh_image_list() + + # Refresh thumbnail model so folder stats (e.g., recycle bin count) update + if self._thumbnail_model: + self._thumbnail_model.refresh() if self.image_files: self._reposition_after_delete(None, previous_index) # Clear cache and invalidate display generation to force image reload @@ -2364,7 +2823,11 @@ def _delete_single_image(self, index: int): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - def _reposition_after_delete(self, preserved_path: Optional[Path], previous_index: int): + self.update_status_message(status_msg) + + def _reposition_after_delete( + self, preserved_path: Optional[Path], previous_index: int + ): """Reposition current_index after the image list refreshed post-deletion.""" if not self.image_files: self.current_index = 0 @@ -2392,102 +2855,238 @@ def delete_batch_images(self): if not self.image_files: self.update_status_message("No images to delete.") return - + # Collect all indices in batches indices_to_delete = set() for start, end in self.batches: for i in range(start, end + 1): if 0 <= i < len(self.image_files): indices_to_delete.add(i) - + if not indices_to_delete: self.update_status_message("No images in batch to delete.") return - + # Sort indices in reverse order so we delete from end to start # This way indices don't shift as we delete sorted_indices = sorted(indices_to_delete, reverse=True) # Determine where to land after deletion - # We prefer to land on the image that was *conceptually* at the same position, - # which means following the last deleted index if we were deleting from right to left, - # or just staying at the start index of the batch. - - # If we just deleted a batch at the end of the list, we clamp to new length-1 - # If we deleted a batch in the middle, we want to be at the index that *was* - # immediately after the batch (which now shifts down by deleted_count). - - # Simpler logic: - # If we had a batch starting at index S with N items. - # After deleting N items, the item that was at S+N matches the new item at S. - # So we should generally effectively stay at 'start' (which finds the next image). - # We need to find the smallest index that was part of the deletion. min_deleted_index = min(sorted_indices) - - # Create recycle bin if it doesn't exist - try: - self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: - self.update_status_message(f"Failed to create recycle bin: {e}") - log.error("Failed to create recycle bin directory: %s", e) - return - + + # Try to ensure recycle bin is available + recycle_bin_available = self._ensure_recycle_bin_dir() + deleted_count = 0 + permanent_delete_mode = not recycle_bin_available import time + timestamp = time.time() - - # Delete all images in the batch - for index in sorted_indices: - if index >= len(self.image_files): - continue - - image_file = self.image_files[index] - jpg_path = image_file.path - raw_path = image_file.raw_pair - - try: - recycled_jpg = self._move_to_recycle(jpg_path) - recycled_raw = self._move_to_recycle(raw_path) if (raw_path and raw_path.exists()) else None - - if recycled_jpg or recycled_raw: - record = ( (jpg_path, recycled_jpg), (raw_path, recycled_raw) ) - self.delete_history.append(record) - self.undo_history.append(("delete", record, timestamp)) + + # Collect images to delete first + images_to_delete = [ + self.image_files[i] for i in sorted_indices if i < len(self.image_files) + ] + + if permanent_delete_mode and images_to_delete: + # Show single batch confirmation for all images + if not self._confirm_batch_permanent_delete( + images_to_delete, + reason="Recycle bin could not be created due to permissions.", + ): + self.update_status_message("Deletion cancelled.") + return + + # Delete all confirmed images + for image_file in images_to_delete: + if self._permanently_delete_image_files(image_file): deleted_count += 1 - - except OSError as e: - log.exception("Failed to delete image at index %d: %s", index, e) - + else: + # Normal path: move to recycle bin + for image_file in images_to_delete: + jpg_path = image_file.path + raw_path = image_file.raw_pair + try: + recycled_jpg = self._move_to_recycle(jpg_path) + recycled_raw = ( + self._move_to_recycle(raw_path) + if (raw_path and raw_path.exists()) + else None + ) + + if recycled_jpg or recycled_raw: + record = ((jpg_path, recycled_jpg), (raw_path, recycled_raw)) + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + deleted_count += 1 + + except OSError as e: + log.exception("Failed to delete image %s: %s", jpg_path.name, e) + if deleted_count > 0: # Clear all batches after deletion self.batches = [] self.batch_start_index = None self._invalidate_batch_cache() + # Clear folder stats cache so recycle bin count updates + clear_raw_count_cache() + # Refresh image list self.refresh_image_list() - + if self.image_files: # Calculate new index - # We essentially want to be at 'min_deleted_index' - # But clamped to boundaries. new_index = min_deleted_index new_index = max(0, min(new_index, len(self.image_files) - 1)) - + self.current_index = new_index - + # Clear cache and invalidate display generation to force image reload self.display_generation += 1 self.image_cache.clear() self.prefetcher.cancel_all() # Cancel stale tasks since image list changed self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - - self.update_status_message(f"Deleted {deleted_count} image(s)") + + if permanent_delete_mode: + self.update_status_message( + f"Permanently deleted {deleted_count} image(s) (recycle bin unavailable)" + ) + else: + self.update_status_message(f"Deleted {deleted_count} image(s)") log.info("Deleted %d image(s) from batch", deleted_count) else: self.update_status_message("No images were deleted.") + def _delete_grid_selected_images(self, selected_paths: list): + """Delete images selected in grid view.""" + if not selected_paths: + self.update_status_message("No images selected.") + return + + # Build a path -> index map for the main image list + path_to_index = {} + for i, img in enumerate(self.image_files): + path_to_index[img.path.resolve()] = i + + # Find indices for selected paths + indices_to_delete = set() + for path in selected_paths: + resolved = path.resolve() + if resolved in path_to_index: + indices_to_delete.add(path_to_index[resolved]) + + if not indices_to_delete: + self.update_status_message("Selected images not found in current list.") + return + + # Sort indices in reverse order so we delete from end to start + sorted_indices = sorted(indices_to_delete, reverse=True) + min_deleted_index = min(sorted_indices) + + # Try to ensure recycle bin is available - NO LONGER NEEDED, we try per-file + # recycle_bin_available = self._ensure_recycle_bin_dir() + + deleted_count = 0 + permanent_delete_mode = ( + False # Default to recycle bin, fallback if needed per file + ) + import time + + timestamp = time.time() + + # Collect images to delete first + images_to_delete = [ + self.image_files[i] for i in sorted_indices if i < len(self.image_files) + ] + + # Normal path: move to recycle bin + failed_deletes = [] # List of (ImageFile, exception) + + for image_file in images_to_delete: + jpg_path = image_file.path + raw_path = image_file.raw_pair + + try: + # Use new per-folder move + recycled_jpg = self._move_to_recycle(jpg_path) + # Only try to recycle RAW if it exists + recycled_raw = ( + self._move_to_recycle(raw_path) + if (raw_path and raw_path.exists()) + else None + ) + + if recycled_jpg or recycled_raw: + record = ((jpg_path, recycled_jpg), (raw_path, recycled_raw)) + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + deleted_count += 1 + + except OSError as e: + # Move failed - likely permission or lock issue + failed_deletes.append((image_file, e)) + + # Handle failures reactively + if failed_deletes: + log.warning( + "%d files failed to recycle, prompting for permanent delete", + len(failed_deletes), + ) + # Prompt to permanent delete the ones that failed + failed_images = [err[0] for err in failed_deletes] + + if self._confirm_batch_permanent_delete( + failed_images, + reason="Recycle bin partial failure (files failed to move).", + ): + for img in failed_images: + if self._permanently_delete_image_files(img): + deleted_count += 1 + else: + self.update_status_message( + f"Deleted {deleted_count} files. {len(failed_deletes)} failed." + ) + + if deleted_count > 0: + # Clear grid selection + self._thumbnail_model.clear_selection() + + # Clear folder stats cache so recycle bin count updates + clear_raw_count_cache() + + # Refresh image list + self.refresh_image_list() + + # Refresh the grid model to remove deleted images + self._thumbnail_model.refresh() + self._path_resolver.update_from_model(self._thumbnail_model) + + if self.image_files: + # Calculate new index for loupe view + new_index = min_deleted_index + new_index = max(0, min(new_index, len(self.image_files) - 1)) + self.current_index = new_index + + # Clear cache and invalidate display generation + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + # Restart prefetch for the new current image + self.prefetcher.update_prefetch(self.current_index) + + self.sync_ui_state() + if permanent_delete_mode: + self.update_status_message( + f"Permanently deleted {deleted_count} image(s) (recycle bin unavailable)" + ) + else: + self.update_status_message(f"Deleted {deleted_count} image(s)") + log.info("Deleted %d image(s) from grid selection", deleted_count) + else: + self.update_status_message("No images were deleted.") + def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> bool: """ Robustly restores a backup file to its original location, handling @@ -2503,31 +3102,37 @@ def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> boo log.warning("Backup %s missing but original exists.", backup_path) else: self.update_status_message("Backup not found") - log.warning("Backup %s disappeared before it could be restored.", backup_path) + log.warning( + "Backup %s disappeared before it could be restored.", backup_path + ) return False # Generate a unique temporary path to avoid collisions - temp_path = saved_path.with_suffix(f'.{uuid.uuid4().hex}.tmp_restore') - + temp_path = saved_path.with_suffix(f".{uuid.uuid4().hex}.tmp_restore") + try: # 1. If the target exists, we need to move the backup to the temp location first, # then try to swap. If target is locked, we can't delete it directly. if saved_path.exists(): try: - saved_path.unlink() # Try the easy way first + saved_path.unlink() # Try the easy way first except PermissionError as pe: - log.warning("File %s locked, attempting safe restore strategy: %s", saved_path, pe) - + log.warning( + "File %s locked, attempting safe restore strategy: %s", + saved_path, + pe, + ) + # Move backup to temp - try: + try: shutil.move(str(backup_path), str(temp_path)) except OSError as e: - log.error("Failed to move backup to temp: %s", e) - raise + log.error("Failed to move backup to temp: %s", e) + raise if not temp_path.exists(): - log.error("Temp file %s not found after move!", temp_path) - raise OSError(f"Failed to create temp file {temp_path}") + log.error("Temp file %s not found after move!", temp_path) + raise OSError(f"Failed to create temp file {temp_path}") # Try to force-move the temp file over the target (replace) try: @@ -2537,19 +3142,21 @@ def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> boo log.error("Could not overwrite locked file %s", saved_path) shutil.move(str(temp_path), str(backup_path)) raise - + # 2. If target doesn't exist (successfully unlinked or didn't exist), move backup to target if not saved_path.exists(): # If we moved to temp, move temp -> target source = temp_path if temp_path.exists() else backup_path shutil.move(str(source), str(saved_path)) - + # Verify restoration if not saved_path.exists(): - raise OSError(f"Restoration failed: {saved_path} does not exist after move.") - + raise OSError( + f"Restoration failed: {saved_path} does not exist after move." + ) + if saved_path.stat().st_size == 0: - log.warning("Restored file %s is 0 bytes!", saved_path) + log.warning("Restored file %s is 0 bytes!", saved_path) log.info("Successfully restored %s from %s", saved_path, backup_path_str) return True @@ -2559,192 +3166,252 @@ def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> boo if temp_path.exists(): try: if backup_path.exists(): - temp_path.unlink() # Backup still there, just kill temp + temp_path.unlink() # Backup still there, just kill temp else: - shutil.move(str(temp_path), str(backup_path)) # Put it back + shutil.move(str(temp_path), str(backup_path)) # Put it back except OSError: pass log.exception("Detailed error in _restore_backup_safe") raise e + @Slot() + def _restore_from_recycle_bin_safe( + self, src_path: Path, bin_path: Path + ) -> Tuple[bool, str]: + """Restores file from recycle bin safely. + + Returns: + (success: bool, reason: str) + Reasons: "ok", "missing_in_bin", "dest_exists", "move_failed" + """ + if not bin_path.exists(): + return False, "missing_in_bin" + if src_path.exists(): + return False, "dest_exists" + + try: + src_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(bin_path), str(src_path)) + if src_path.exists(): + return True, "ok" + else: + return False, "move_failed" + except OSError as e: + log.error(f"Failed to restore {bin_path.name}: {e}") + return False, "move_failed" + + def _post_undo_refresh_and_select( + self, target: Path, *, update_hist: bool = False + ) -> None: + """Centralized logic for refreshing state after an undo action.""" + self.refresh_image_list() + + # Find index of restored image + target_resolve = target.resolve() + for i, img_file in enumerate(self.image_files): + try: + if img_file.path.resolve() == target_resolve: + self.current_index = i + break + except OSError: + continue + + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + if update_hist and self.ui_state.isHistogramVisible: + self.update_histogram() + @Slot() def undo_delete(self): """Unified undo that handles both delete and auto white balance operations.""" if not self.undo_history: self.update_status_message("Nothing to undo.") return - + # Get the most recent action action_type, action_data, timestamp = self.undo_history.pop() - + if action_type == "delete": - # New record format: ( (jpg_src, jpg_bin), (raw_src, raw_bin) ) - (jpg_src, jpg_bin), (raw_src, raw_bin) = action_data - - # Remove from delete_history if it matches + try: + # Guard unpacking to prevent crashes on old history formats + (jpg_pair, raw_pair) = action_data + (jpg_src, jpg_bin) = jpg_pair + (raw_src, raw_bin) = raw_pair + except Exception: + self.update_status_message("Undo failed: unexpected undo record format") + log.exception("Unexpected undo record format: %r", action_data) + return + + # Remove from delete_history only if it matches (prevent duplicates) + popped_delete_history = False if self.delete_history and self.delete_history[-1] == action_data: self.delete_history.pop() - + popped_delete_history = True + restored_files = [] - try: - # Helper to move back safely - def restore_file(src_path: Optional[Path], bin_path: Optional[Path]): - if not src_path or not bin_path or not bin_path.exists(): - return False - if src_path.exists(): - log.warning("Cannot restore %s: User file already exists at %s", bin_path.name, src_path) - return False # Or maybe restore with new name? For now, skip to prevent overwrite - - shutil.move(str(bin_path), str(src_path)) - return True - - # Restore JPG - if restore_file(jpg_src, jpg_bin): - restored_files.append(jpg_src.name) - log.info("Restored %s from recycle bin", jpg_src.name) - - # Restore RAW - if restore_file(raw_src, raw_bin): + jpg_res_ok = False + + # --- Jpeg Restore --- + success, reason = self._restore_from_recycle_bin_safe(jpg_src, jpg_bin) + if success: + jpg_res_ok = True + restored_files.append(jpg_src.name) + log.info("Restored %s from recycle bin", jpg_src.name) + elif reason == "dest_exists": + log.warning( + "Restore skipped for %s: destination already exists", jpg_src.name + ) + # We consider this "success" enough to proceed to RAW, but we didn't restore it. + else: + # Failed hard + self.update_status_message(f"Undo failed: {reason} for {jpg_src.name}") + # Put back history + self.undo_history.append(("delete", action_data, timestamp)) + if popped_delete_history: + self.delete_history.append(action_data) + return + + # --- Raw Restore --- + if raw_src and raw_bin: + success, reason = self._restore_from_recycle_bin_safe(raw_src, raw_bin) + if success: restored_files.append(raw_src.name) log.info("Restored %s from recycle bin", raw_src.name) - - # Update status - if restored_files: - files_str = ", ".join(restored_files) - self.update_status_message(f"Restored: {files_str}") + elif reason == "dest_exists": + # Non-fatal: just warn that we kept the existing RAW + log.warning( + "Restore skipped for %s: destination already exists", + raw_src.name, + ) + restored_files.append(f"{raw_src.name} (existed)") else: - self.update_status_message("No files to restore") - - # Refresh image list - self.refresh_image_list() - - # Find and navigate to the restored image - for i, img_file in enumerate(self.image_files): - if img_file.path == jpg_src: - self.current_index = i - break - - # Clear cache and invalidate display generation to force image reload - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() # Cancel stale tasks since image list changed - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - except OSError as e: - self.update_status_message(f"Undo failed: {e}") - log.exception("Failed to restore image") - # Put it back in history if it failed - self.undo_history.append(("delete", action_data, timestamp)) - self.delete_history.append(action_data) - + # RAW restore failed (move failed or bin missing). + # If we restored JPG, we should probably rollback for consistency + # UNLESS the user prefers partial restore. + # Current plan: Rollback JPG if RAW failed hard. + if jpg_res_ok: + log.warning( + "RAW restore failed (%s), rolling back JPG for atomicity", + reason, + ) + try: + # Attempt to move JPG back to bin + shutil.move(str(jpg_src), str(jpg_bin)) + except OSError as e: + log.error("Failed to rollback JPG: %s", e) + self.update_status_message( + "Partial restore error (manual cleanup needed)" + ) + return # Do not put back in history, state is mixed + + self.update_status_message( + f"Undo failed: {reason} for {raw_src.name}" + ) + self.undo_history.append(("delete", action_data, timestamp)) + if popped_delete_history: + self.delete_history.append(action_data) + return + + # --- Success Path --- + if restored_files: + files_str = ", ".join(restored_files) + self.update_status_message(f"Restored: {files_str}") + else: + self.update_status_message("No files restored (destinations existed)") + + # Use helper to refresh + self._post_undo_refresh_and_select(jpg_src, update_hist=False) + + # Refresh grid explicitly for recycle bin counts + if self._thumbnail_model and self._is_grid_view_active: + self._thumbnail_model.refresh() + elif action_type == "auto_white_balance": saved_path, backup_path = action_data try: if self._restore_backup_safe(saved_path, backup_path): - # Refresh - self.refresh_image_list() - # Find - saved_path_obj = Path(saved_path) - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path_obj: - self.current_index = i - break - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - if self.ui_state.isHistogramVisible: - self.update_histogram() - + self._post_undo_refresh_and_select( + Path(saved_path), update_hist=True + ) self.update_status_message("Undid auto white balance") except Exception as e: self.update_status_message(f"Undo failed: {e}") if Path(backup_path).exists(): - self.undo_history.append(("auto_white_balance", action_data, timestamp)) + self.undo_history.append( + ("auto_white_balance", action_data, timestamp) + ) elif action_type == "auto_levels": saved_path, backup_path = action_data try: if self._restore_backup_safe(saved_path, backup_path): - # Refresh - self.refresh_image_list() - # Find - saved_path_obj = Path(saved_path) - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path_obj: - self.current_index = i - break - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - if self.ui_state.isHistogramVisible: - self.update_histogram() - + self._post_undo_refresh_and_select( + Path(saved_path), update_hist=True + ) self.update_status_message("Undid auto levels") except Exception as e: self.update_status_message(f"Undo failed: {e}") if Path(backup_path).exists(): - self.undo_history.append(("auto_levels", action_data, timestamp)) + self.undo_history.append(("auto_levels", action_data, timestamp)) elif action_type == "crop": saved_path, backup_path = action_data try: if self._restore_backup_safe(saved_path, backup_path): - # Refresh - self.refresh_image_list() - # Find - saved_path_obj = Path(saved_path) - for i, img_file in enumerate(self.image_files): - if img_file.path == saved_path_obj: - self.current_index = i - break - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - + self._post_undo_refresh_and_select( + Path(saved_path), update_hist=False + ) self.update_status_message("Undid crop") except Exception as e: self.update_status_message(f"Undo failed: {e}") if Path(backup_path).exists(): - self.undo_history.append(("crop", action_data, timestamp)) + self.undo_history.append(("crop", action_data, timestamp)) def shutdown(self): log.info("Application shutting down.") - - # Check if recycle bin has files and prompt to empty - if self.recycle_bin_dir.exists(): - files_in_bin = list(self.recycle_bin_dir.glob("*")) - if files_in_bin: - file_count = len(files_in_bin) - msg_box = QMessageBox() - msg_box.setWindowTitle("Recycle Bin") - msg_box.setText(f"There are {file_count} files in the recycle bin.") - msg_box.setInformativeText("What would you like to do?") - - # Add custom buttons - delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.YesRole) - restore_btn = msg_box.addButton(f"Restore {file_count} deleted files", QMessageBox.ActionRole) - keep_btn = msg_box.addButton("Keep in Recycle Bin", QMessageBox.NoRole) - - msg_box.setDefaultButton(keep_btn) - msg_box.exec() - - clicked_button = msg_box.clickedButton() - if clicked_button == delete_btn: - self.empty_recycle_bin() - elif clicked_button == restore_btn: - self.restore_all_from_recycle_bin() - + + # Check tracked recycle bins plus explicit base bin check if it exists + bins_to_check = set(self.active_recycle_bins) + # Also check local "image recycle bin" in current/base folders just in case + try: + bins_to_check.add(self.image_dir / "image recycle bin") + except Exception: + pass + + total_files = 0 + bin_stats = {} # bin_path -> count + + for bin_dir in bins_to_check: + if bin_dir.exists() and bin_dir.is_dir(): + try: + stats = get_file_counts_by_extension(bin_dir) + count = sum(stats.values()) + if count > 0: + total_files += count + bin_stats[bin_dir] = count + except OSError: + pass + + if total_files > 0: + # In a GUI app, we can't easily show a blocking QMessageBox here because shutdown() + # might be called after loop exit or during cleanup. + # However, we implemented the QML close interception (Main.qml) which calls cleanup. + # This python-side check is a fallback or for CLI usage. + # Since we moved the logic to QML's onClosing, we mainly just log here. + log.info( + "Shutdown with %d files in recycle bins: %s", + total_files, + list(bin_stats.keys()), + ) + # Clear QML context property to prevent TypeErrors during shutdown if self.engine: log.info("Clearing uiState context property in QML.") - del self.engine # Explicitly delete the engine + del self.engine # Explicitly delete the engine self.watcher.stop() self.prefetcher.shutdown() @@ -2762,79 +3429,91 @@ def _shutdown_executors(self): self._preview_executor.shutdown(wait=False, cancel_futures=True) def empty_recycle_bin(self): - """Permanently deletes all files in the recycle bin.""" - if not self.recycle_bin_dir.exists(): - return - + """Permanently deletes all files in all tracked recycle bins.""" + # Clean up tracked bins + bins_to_clean = set(self.active_recycle_bins) + # Check base bin too try: - import shutil - shutil.rmtree(self.recycle_bin_dir) - self.delete_history.clear() - log.info("Emptied recycle bin and cleared delete history") - except OSError: - log.exception("Failed to empty recycle bin") - + bins_to_clean.add(self.image_dir / "image recycle bin") + except Exception: + pass + + for bin_path in bins_to_clean: + if bin_path.exists(): + try: + shutil.rmtree(bin_path) + except OSError: + log.exception("Failed to empty recycle bin %s", bin_path) + + self.active_recycle_bins.clear() + self.delete_history.clear() + clear_raw_count_cache() + log.info("Emptied recycle bins and cleared delete history") + def _on_cache_evict(self): """Callback for when the image cache evicts an item.""" now = time.time() - + # 1. Record eviction timestamp self._eviction_timestamps.append(now) - + # 2. Prune timestamps older than window # Keep list short cutoff = now - CACHE_THRASH_WINDOW_SECS self._eviction_timestamps = [t for t in self._eviction_timestamps if t > cutoff] - + # 3. Check for thrashing (e.g., > threshold evictions in window) if len(self._eviction_timestamps) > CACHE_THRASH_THRESHOLD: # 4. Rate limit the warning if now - self._last_cache_warning_time > CACHE_WARNING_COOLDOWN_SECS: self._last_cache_warning_time = now self._has_warned_cache_full = True - + # Format usage info used_gb = self.image_cache.currsize / (1024**3) max_gb = self.image_cache.max_bytes / (1024**3) - + msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in {CACHE_THRASH_WINDOW_SECS}s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB." - + # Use QTimer.singleShot to ensure this runs on the main thread QTimer.singleShot(0, lambda: self.update_status_message(msg)) log.warning(msg) def restore_all_from_recycle_bin(self): - """Restores all files from recycle bin to working directory.""" - if not self.recycle_bin_dir.exists(): - return - + """Restores all files from tracked recycle bins to their parent folders.""" + restored_count = 0 + + bins_to_restore = set(self.active_recycle_bins) try: - files_in_bin = list(self.recycle_bin_dir.glob("*")) - restored_count = 0 - - for file_in_bin in files_in_bin: - # Restore to original location (working directory) - dest_path = self.image_dir / file_in_bin.name - - # If file already exists, skip (don't overwrite) - if dest_path.exists(): - log.warning("File already exists, skipping: %s", dest_path) - continue - - try: - file_in_bin.rename(dest_path) - restored_count += 1 - log.info("Restored %s from recycle bin", file_in_bin.name) - except OSError as e: - log.error("Failed to restore %s: %s", file_in_bin.name, e) - - # Clear delete history since we restored everything - self.delete_history.clear() - - log.info("Restored %d files from recycle bin", restored_count) - - except OSError: - log.exception("Failed to restore files from recycle bin") + bins_to_restore.add(self.image_dir / "image recycle bin") + except Exception: + pass + + for bin_path in bins_to_restore: + if not bin_path.exists(): + continue + + restore_target = bin_path.parent + try: + for file_in_bin in bin_path.iterdir(): + dest_path = restore_target / file_in_bin.name + if dest_path.exists(): + log.warning("File already exists, skipping: %s", dest_path) + continue + + try: + shutil.move(str(file_in_bin), str(dest_path)) + restored_count += 1 + log.info("Restored %s from %s", file_in_bin.name, bin_path.name) + except OSError as e: + log.error("Failed to restore %s: %s", file_in_bin.name, e) + except OSError: + log.exception("Failed to iterate recycle bin %s", bin_path) + + # Clear delete history since we restored everything + self.delete_history.clear() + + log.info("Restored %d files from recycle bins", restored_count) @Slot() def edit_in_photoshop(self): @@ -2845,13 +3524,12 @@ def edit_in_photoshop(self): # Prefer RAW file if it exists, otherwise use JPG image_file = self.image_files[self.current_index] jpg_path = image_file.path - + # Handle backup images: strip -backup, -backup2, -backup-1, etc. to find original RAW - import re original_stem = jpg_path.stem # Remove -backup with optional digits or -backup-digits (handles both formats) - original_stem = re.sub(r'-backup(-?\d+)?$', '', original_stem) - + original_stem = re.sub(r"-backup(-?\d+)?$", "", original_stem) + # Look for RAW file with the original stem raw_path = None if image_file.raw_pair and image_file.raw_pair.exists(): @@ -2859,61 +3537,62 @@ def edit_in_photoshop(self): raw_path = image_file.raw_pair else: # Search for RAW file manually by original stem - from faststack.io.indexer import RAW_EXTENSIONS for ext in RAW_EXTENSIONS: potential_raw = jpg_path.parent / f"{original_stem}{ext}" if potential_raw.exists(): raw_path = potential_raw break - + if raw_path and raw_path.exists(): current_image_path = raw_path log.info("Using RAW file for Photoshop: %s", raw_path) else: current_image_path = jpg_path - log.info("Using JPG file for Photoshop (no RAW found): %s", current_image_path) - - photoshop_exe = config.get('photoshop', 'exe') - photoshop_args = config.get('photoshop', 'args') + log.info( + "Using JPG file for Photoshop (no RAW found): %s", current_image_path + ) + + photoshop_exe = config.get("photoshop", "exe") + photoshop_args = config.get("photoshop", "args") # Validate executable path securely is_valid, error_msg = validate_executable_path( - photoshop_exe, - app_type="photoshop", - allow_custom_paths=True + photoshop_exe, app_type="photoshop", allow_custom_paths=True ) - + if not is_valid: self.update_status_message(f"Photoshop validation failed: {error_msg}") log.error("Photoshop executable validation failed: %s", error_msg) return - + # Validate that the file path exists and is a file if not current_image_path.exists() or not current_image_path.is_file(): - self.update_status_message(f"Image file not found: {current_image_path.name}") + self.update_status_message( + f"Image file not found: {current_image_path.name}" + ) log.error("Image file not found or not a file: %s", current_image_path) return try: # Build command list safely command = [photoshop_exe] - + # Parse additional args safely using shlex (handles quotes and escapes properly) if photoshop_args: try: # Use shlex to properly parse arguments with quotes/escapes # On Windows, use posix=False to handle Windows-style paths - parsed_args = shlex.split(photoshop_args, posix=(os.name != 'nt')) + parsed_args = shlex.split(photoshop_args, posix=(os.name != "nt")) command.extend(parsed_args) except ValueError as e: log.error("Invalid photoshop_args format: %s", e) self.update_status_message("Invalid Photoshop arguments configured") return - + # Add the file path as the last argument # Convert to string but keep it as a list element (not shell-interpolated) command.append(str(current_image_path.resolve())) - + # SECURITY: Explicitly disable shell execution subprocess.Popen( command, @@ -2921,11 +3600,10 @@ def edit_in_photoshop(self): stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - close_fds=True # Close unused file descriptors + close_fds=True, # Close unused file descriptors ) - + # Mark as edited on successful launch - from datetime import datetime today = datetime.now().strftime("%Y-%m-%d") stem = image_file.path.stem meta = self.sidecar.get_metadata(stem) @@ -2935,8 +3613,10 @@ def edit_in_photoshop(self): self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.sync_ui_state() - - self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") + + self.update_status_message( + f"Opened {current_image_path.name} in Photoshop." + ) log.info("Launched Photoshop with: %s", command) except FileNotFoundError as e: self.update_status_message(f"Photoshop executable not found: {e}") @@ -2971,6 +3651,7 @@ def update_status_message(self, message: str, timeout: int = 3000): """ Updates the UI status message and clears it after a timeout. """ + def clear_message(): if self.ui_state.statusMessage == message: self.ui_state.statusMessage = "" @@ -2978,8 +3659,6 @@ def clear_message(): self.ui_state.statusMessage = message QTimer.singleShot(timeout, clear_message) - - @Slot() def start_drag_current_image(self): if not self.image_files or self.current_index >= len(self.image_files): @@ -2988,33 +3667,37 @@ def start_drag_current_image(self): # Collect all files: current + any in defined batches files_to_drag = set() files_to_drag.add(self.current_index) - + # Add all files from defined batches for start, end in self.batches: for idx in range(start, end + 1): if 0 <= idx < len(self.image_files): files_to_drag.add(idx) - + # Convert to sorted list and get only existing paths file_indices = sorted(files_to_drag) - existing_indices = [idx for idx in file_indices if self.image_files[idx].path.exists()] - - # Prefer dragging the developed JPG if it exists (for external export), + existing_indices = [ + idx for idx in file_indices if self.image_files[idx].path.exists() + ] + + # Prefer dragging the developed JPG if it exists (for external export), # but only when RAW mode is active or we are dragging a developed file itself. file_paths = [] for idx in existing_indices: img = self.image_files[idx] - - # Suggestion: only prefer -developed.jpg when RAW mode is active + + # Suggestion: only prefer -developed.jpg when RAW mode is active # or when the current entry is itself the working/developed artifact. is_developed_artifact = img.path.stem.lower().endswith("-developed") - in_raw_mode = (getattr(self, 'current_edit_source_mode', 'jpeg') == "raw") - - if (in_raw_mode or is_developed_artifact) and img.developed_jpg_path.exists(): + in_raw_mode = getattr(self, "current_edit_source_mode", "jpeg") == "raw" + + if ( + in_raw_mode or is_developed_artifact + ) and img.developed_jpg_path.exists(): file_paths.append(img.developed_jpg_path) else: file_paths.append(img.path) - + if not file_paths: log.error("No valid files to drag") return @@ -3028,7 +3711,7 @@ def start_drag_current_image(self): # Use Qt's standard setUrls - it handles both browser and native app compatibility urls = [QUrl.fromLocalFile(str(p)) for p in file_paths] mime_data.setUrls(urls) - + drag.setMimeData(mime_data) # --- thumbnail / drag preview --- @@ -3040,17 +3723,22 @@ def start_drag_current_image(self): # hotspot = center of image drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) - log.info("Starting drag for %d file(s): %s", len(file_paths), [str(p) for p in file_paths]) + log.info( + "Starting drag for %d file(s): %s", + len(file_paths), + [str(p) for p in file_paths], + ) # Support both Copy and Move actions for browser compatibility result = drag.exec(Qt.CopyAction | Qt.MoveAction) log.info("Drag completed with result: %s", result) - + # Reset zoom/pan after drag completes (drag can cause unwanted panning) self.ui_state.resetZoomPan() - + # Mark all dragged files as uploaded if drag was successful if result in (Qt.CopyAction, Qt.MoveAction): from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") for idx in existing_indices: @@ -3058,9 +3746,9 @@ def start_drag_current_image(self): meta = self.sidecar.get_metadata(stem) meta.uploaded = True meta.uploaded_date = today - + self.sidecar.save() - + # Clear all batches after successful drag (like pressing \) self.batches = [] self.batch_start_index = None @@ -3069,16 +3757,18 @@ def start_drag_current_image(self): self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.sync_ui_state() - log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) - - + log.info( + "Marked %d file(s) as uploaded on %s. Cleared all batches.", + len(existing_indices), + today, + ) @Slot() def enable_raw_editing(self): """Switches the current image to RAW mode (using developed TIFF).""" if not self.image_files: return - + # 1. Update State # 1. Update State if self.current_edit_source_mode != "raw": @@ -3088,13 +3778,13 @@ def enable_raw_editing(self): # 2. Check if we have a valid TIFF ready path = self.get_active_edit_path(self.current_index) - + # If the path returned IS the working TIFF (and it exists), we can just load it. # Check specific condition: image_file = self.image_files[self.current_index] if path == image_file.working_tif_path and self.is_valid_working_tif(path): log.info("Valid working TIFF exists, switching to RAW mode immediately.") - self.load_image_for_editing() # This will now pick up the TIFF via get_active_edit_path + self.load_image_for_editing() # This will now pick up the TIFF via get_active_edit_path return # 3. If not ready, trigger development @@ -3116,6 +3806,7 @@ def _develop_raw_backend(self): # Resolve RawTherapee Executable from faststack.config import config + rt_exe = config.get("rawtherapee", "exe") if not rt_exe or not os.path.exists(rt_exe): self.update_status_message("RawTherapee not found. Check settings.") @@ -3127,7 +3818,7 @@ def _develop_raw_backend(self): def worker(): # Check for optional args in config rt_args = config.get("rawtherapee", "args") - + # Build command: rawtherapee-cli -t -Y -o -c # -t: TIFF output # -b16: 16-bit depth (Critical! Default is often 8-bit) @@ -3135,16 +3826,16 @@ def worker(): # -o: Output file # -c: Input file (must be last) cmd = [rt_exe, "-t", "-b16", "-Y", "-o", str(tif_path)] - + if rt_args: try: # Use shlex to properly parse arguments with quotes/escapes # On Windows, use posix=False to handle Windows-style paths - parsed_args = shlex.split(rt_args, posix=(os.name != 'nt')) + parsed_args = shlex.split(rt_args, posix=(os.name != "nt")) cmd.extend(parsed_args) except ValueError as e: log.error("Invalid rawtherapee args format: %s", e) - + cmd.extend(["-c", str(raw_path)]) cmd_str = " ".join(cmd) # For logging @@ -3152,7 +3843,7 @@ def worker(): run_kwargs = { "capture_output": True, "text": True, - "timeout": 60 # 60 second timeout + "timeout": 60, # 60 second timeout } if sys.platform == "win32": run_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW @@ -3164,57 +3855,66 @@ def worker(): if tif_path.exists() and tif_path.stat().st_size > 0: log.info("RAW development successful.") # Use partial to bind variable deeply - QTimer.singleShot(0, functools.partial(self._on_develop_finished, True, None)) - return # Success path + QTimer.singleShot( + 0, functools.partial(self._on_develop_finished, True, None) + ) + return # Success path else: msg = f"RawTherapee exited successfully but output file is missing or empty.\nCommand: {cmd_str}" log.error(msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, msg)) + QTimer.singleShot( + 0, functools.partial(self._on_develop_finished, False, msg) + ) else: stderr = result.stderr.strip() if result.stderr else "(no stderr)" stdout = result.stdout.strip() if result.stdout else "(no stdout)" err_msg = f"RawTherapee failed (exit code {result.returncode}):\nCommand: {cmd_str}\nstderr: {stderr}\nstdout: {stdout}" log.error(err_msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) + QTimer.singleShot( + 0, functools.partial(self._on_develop_finished, False, err_msg) + ) except subprocess.TimeoutExpired: err_msg = f"RawTherapee timed out after 60 seconds.\nCommand: {cmd_str}" log.error(err_msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) + QTimer.singleShot( + 0, functools.partial(self._on_develop_finished, False, err_msg) + ) except Exception as e: err_msg = f"Unexpected error running RawTherapee: {str(e)}" log.exception(err_msg) - QTimer.singleShot(0, functools.partial(self._on_develop_finished, False, err_msg)) + QTimer.singleShot( + 0, functools.partial(self._on_develop_finished, False, err_msg) + ) finally: # Cleanup if we failed and left a bad file or 0-byte file (unless success logic already returned) # Note: success logic returns early. If we are here, we likely failed or fell through (e.g. 0 byte file case did not return) # Actually, the 0-byte case calls on_finished but doesn't return, so it falls here. # Let's check specifically if we need to cleanup. # If we succeeded, we returned. - if tif_path.exists() and 'result' in locals(): - # Only cleanup if result was assigned (subprocess ran) - # If it's 0 bytes or we are in an error state (which implies we didn't return early) - try: + if tif_path.exists() and "result" in locals(): + # Only cleanup if result was assigned (subprocess ran) + # If it's 0 bytes or we are in an error state (which implies we didn't return early) + try: if tif_path.stat().st_size == 0: - tif_path.unlink() + tif_path.unlink() elif result.returncode != 0: - # If we crashed but left a file, delete it - tif_path.unlink() - except (OSError, AttributeError): - # AttributeError if result is None - pass + # If we crashed but left a file, delete it + tif_path.unlink() + except (OSError, AttributeError): + # AttributeError if result is None + pass threading.Thread(target=worker, daemon=True).start() - # Preserving legacy slot name for compatibility if QML calls it directly, - # but QML should call enable_raw_editing now. + # Preserving legacy slot name for compatibility if QML calls it directly, + # but QML should call enable_raw_editing now. # Actually provider.py calls this. I will update provider.py to call enable_raw_editing. # But I'll keep this as a proxy to the new method just in case. @Slot() def develop_raw_for_current_image(self): self.enable_raw_editing() - @Slot() def load_image_for_editing(self): """ @@ -3224,7 +3924,7 @@ def load_image_for_editing(self): try: active_path = self.get_active_edit_path(self.current_index) filepath = str(active_path) - + # Fetch cached preview if available for faster initial display cached_preview = self.get_decoded_image(self.current_index) @@ -3235,30 +3935,37 @@ def load_image_for_editing(self): image_file = self.image_files[self.current_index] jpeg_path = image_file.path # Only if the main path isn't itself a TIFF (avoid recursion) - if jpeg_path.suffix.lower() not in ('.tif', '.tiff') and jpeg_path.exists(): + if ( + jpeg_path.suffix.lower() not in (".tif", ".tiff") + and jpeg_path.exists() + ): try: with Image.open(jpeg_path) as src_im: - source_exif = src_im.info.get('exif') + source_exif = src_im.info.get("exif") except Exception as e: - log.warning(f"Failed to capture source EXIF from {jpeg_path}: {e}") + log.warning( + f"Failed to capture source EXIF from {jpeg_path}: {e}" + ) # Load into editor - if self.image_editor.load_image(filepath, cached_preview=cached_preview, source_exif=source_exif): + if self.image_editor.load_image( + filepath, cached_preview=cached_preview, source_exif=source_exif + ): # Notify UIState to update bindings # We do this via signals or by calling the update function on UIState if available # But UIState listens to editor signals? # Actually, the previous implementation in UIState pushed edits to itself. # We need to preserve that behavior. - # For now, simpler to emit a signal that UIState listens to, + # For now, simpler to emit a signal that UIState listens to, # OR just manually update UIState here if we have reference. if self.ui_state: - self._sync_editor_state_to_ui() - + self._sync_editor_state_to_ui() + return True except Exception as e: log.exception("Failed to load image for editing: %s", e) self.update_status_message(f"Error loading editor: {e}") - + return False def _sync_editor_state_to_ui(self): @@ -3267,12 +3974,13 @@ def _sync_editor_state_to_ui(self): for key, value in initial_edits.items(): if hasattr(self.ui_state, key): setattr(self.ui_state, key, value) - + # Reset visual components - if hasattr(self.ui_state, 'aspectRatioNames'): + if hasattr(self.ui_state, "aspectRatioNames"): # This requires IMPORTs? No, just pass list. from faststack.imaging.editor import ASPECT_RATIOS - self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + + self.ui_state.aspectRatioNames = [r["name"] for r in ASPECT_RATIOS] self.ui_state.currentAspectRatioIndex = 0 self.ui_state.currentCropBox = (0, 0, 1000, 1000) @@ -3301,18 +4009,21 @@ def get_preview_data(self) -> Optional[DecodedImage]: def set_edit_parameter(self, key: str, value: Any): """Sets an edit parameter and updates the UIState for the slider visual.""" # Robust guard: only allow edits if the editor is actually holding an image. - if not self.image_editor: + if not self.image_editor: return - if self.image_editor.current_filepath is None: + if self.image_editor.current_filepath is None: return # Must have either a float image (working copy) or original loaded - if self.image_editor.float_image is None and self.image_editor.original_image is None: + if ( + self.image_editor.float_image is None + and self.image_editor.original_image is None + ): return try: # Update actual edit state (this bumps _edits_rev and invalidates preview cache) changed = self.image_editor.set_edit_param(key, value) - + # Sync UI state with backend (e.g., rotation might be rounded) final_value = value if changed: @@ -3336,45 +4047,45 @@ def set_edit_parameter(self, key: str, value: Any): def set_crop_box(self, left: int, top: int, right: int, bottom: int): """Sets the normalized crop box (0-1000) in the editor.""" from typing import Tuple + crop_box: Tuple[int, int, int, int] = (left, top, right, bottom) self.image_editor.set_crop_box(crop_box) - self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) + self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) @Slot() def reset_edit_parameters(self): """Resets all editing parameters in the editor.""" self.image_editor.reset_edits() - if hasattr(self.ui_state, 'reset_editor_state'): + if hasattr(self.ui_state, "reset_editor_state"): self.ui_state.reset_editor_state() - + self.update_status_message("Edits reset") # Trigger a refresh to show the reset image self.ui_refresh_generation += 1 self._kick_preview_worker() - + if self.ui_state.isHistogramVisible: self.update_histogram() - @Slot() def rotate_image_cw(self): """Rotate the edited image 90 degrees clockwise.""" - current = self.image_editor.current_edits.get('rotation', 0) + current = self.image_editor.current_edits.get("rotation", 0) new_rotation = (current - 90) % 360 - self.set_edit_parameter('rotation', new_rotation) + self.set_edit_parameter("rotation", new_rotation) if self.ui_state.isHistogramVisible: self.update_histogram() @Slot() def rotate_image_ccw(self): """Rotate the edited image 90 degrees counter-clockwise.""" - current = self.image_editor.current_edits.get('rotation', 0) + current = self.image_editor.current_edits.get("rotation", 0) new_rotation = (current + 90) % 360 - self.set_edit_parameter('rotation', new_rotation) + self.set_edit_parameter("rotation", new_rotation) if self.ui_state.isHistogramVisible: self.update_histogram() - + @Slot() def toggle_histogram(self): """Toggle histogram window visibility.""" @@ -3384,12 +4095,18 @@ def toggle_histogram(self): log.info("Histogram window opened") else: log.info("Histogram window closed") - + @Slot() @Slot(float, float, float, float) # zoom, panX, panY, imageScale - def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = 0.0, image_scale: float = 1.0): + def update_histogram( + self, + zoom: float = 1.0, + pan_x: float = 0.0, + pan_y: float = 0.0, + image_scale: float = 1.0, + ): """Throttled request to update histogram. Updates continuously but capped at interval. - + Args: zoom: Zoom scale factor (1.0 = no zoom) pan_x: Pan offset in X direction (in image coordinates) @@ -3405,7 +4122,7 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = with self._hist_lock: self._hist_pending = (zoom, pan_x, pan_y, image_scale) inflight = self._hist_inflight - + if not self.histogram_timer.isActive() and not inflight: self.histogram_timer.start() @@ -3433,7 +4150,9 @@ def _kick_histogram_worker(self): # Fallback for initial load if no edit preview yet (could use get_decoded_image?) # But histogram is mostly for edits. If preview_data is None, we likely can't compute anyway. # We can try to peek at the image editor if _last_rendered_preview is unset. - preview_data = self.image_editor.get_preview_data_cached(allow_compute=False) + preview_data = self.image_editor.get_preview_data_cached( + allow_compute=False + ) # Fallback: If still no preview data (e.g. editor not open), we need to fetch the main image. # But doing get_decoded_image() here blocks the main thread. @@ -3441,7 +4160,7 @@ def _kick_histogram_worker(self): target_index = -1 if not preview_data and 0 <= self.current_index < len(self.image_files): target_index = self.current_index - + # If no preview data AND no valid index, we can't compute. if not preview_data and target_index == -1: # We must clear inflight if we abort, otherwise we deadlock future updates @@ -3450,17 +4169,24 @@ def _kick_histogram_worker(self): self._hist_inflight = False # Restore pending args so the next timer tick (or preview completion) retries if self._hist_pending is None: - self._hist_pending = args + self._hist_pending = args # Make sure timer is running to retry (check under lock to avoid race) should_start_timer = not self.histogram_timer.isActive() - + if should_start_timer: self.histogram_timer.start() return try: # Pass simple data + controller reference + target_index - fut = self._hist_executor.submit(self._compute_histogram_worker, token, args, preview_data, self, target_index) + fut = self._hist_executor.submit( + self._compute_histogram_worker, + token, + args, + preview_data, + self, + target_index, + ) fut.add_done_callback(self._on_histogram_done) except Exception as e: log.error(f"Histogram executor failed to submit task: {e}") @@ -3468,7 +4194,9 @@ def _kick_histogram_worker(self): self._hist_inflight = False @staticmethod - def _compute_histogram_worker(token, args, decoded, controller=None, target_index=-1): + def _compute_histogram_worker( + token, args, decoded, controller=None, target_index=-1 + ): # IMPORTANT: do not touch QObjects here except thread-safe plain data zoom, pan_x, pan_y, image_scale = args @@ -3480,58 +4208,75 @@ def _compute_histogram_worker(token, args, decoded, controller=None, target_inde if not decoded: return token, None - import numpy as np try: - arr = np.frombuffer(decoded.buffer, dtype=np.uint8).reshape((decoded.height, decoded.width, 3)) - + # Validate buffer size before reshape to prevent ValueError + expected_size = decoded.height * decoded.width * 3 + if len(decoded.buffer) != expected_size: + log.warning( + "Histogram: Buffer size mismatch. Expected %d bytes, got %d", + expected_size, + len(decoded.buffer), + ) + return token, None + + arr = np.frombuffer(decoded.buffer, dtype=np.uint8).reshape( + (decoded.height, decoded.width, 3) + ) + # If zoomed in, calculate visible region and only use that portion if zoom > 1.1: - visible_width = decoded.width / zoom - visible_height = decoded.height / zoom - center_x = decoded.width / 2 - center_y = decoded.height / 2 - pan_x_image = pan_x / image_scale if image_scale > 0 else 0 - pan_y_image = pan_y / image_scale if image_scale > 0 else 0 - visible_center_x = center_x - (pan_x_image / zoom) - visible_center_y = center_y - (pan_y_image / zoom) - - visible_x_start = max(0, int(visible_center_x - visible_width / 2)) - visible_y_start = max(0, int(visible_center_y - visible_height / 2)) - visible_x_end = min(decoded.width, int(visible_center_x + visible_width / 2)) - visible_y_end = min(decoded.height, int(visible_center_y + visible_height / 2)) - - if visible_x_end > visible_x_start and visible_y_end > visible_y_start: - arr = arr[visible_y_start:visible_y_end, visible_x_start:visible_x_end, :] + visible_width = decoded.width / zoom + visible_height = decoded.height / zoom + center_x = decoded.width / 2 + center_y = decoded.height / 2 + pan_x_image = pan_x / image_scale if image_scale > 0 else 0 + pan_y_image = pan_y / image_scale if image_scale > 0 else 0 + visible_center_x = center_x - (pan_x_image / zoom) + visible_center_y = center_y - (pan_y_image / zoom) + + visible_x_start = max(0, int(visible_center_x - visible_width / 2)) + visible_y_start = max(0, int(visible_center_y - visible_height / 2)) + visible_x_end = min( + decoded.width, int(visible_center_x + visible_width / 2) + ) + visible_y_end = min( + decoded.height, int(visible_center_y + visible_height / 2) + ) + + if visible_x_end > visible_x_start and visible_y_end > visible_y_start: + arr = arr[ + visible_y_start:visible_y_end, visible_x_start:visible_x_end, : + ] bins = 256 value_range = (0, 256) - + r_hist = np.histogram(arr[:, :, 0], bins=bins, range=value_range)[0] g_hist = np.histogram(arr[:, :, 1], bins=bins, range=value_range)[0] b_hist = np.histogram(arr[:, :, 2], bins=bins, range=value_range)[0] - + r_clip_count = int(r_hist[255]) g_clip_count = int(g_hist[255]) b_clip_count = int(b_hist[255]) - + r_preclip_count = int(np.sum(r_hist[250:255])) g_preclip_count = int(np.sum(g_hist[250:255])) b_preclip_count = int(np.sum(b_hist[250:255])) - + log_r_hist = [float(x) for x in np.log1p(r_hist)] log_g_hist = [float(x) for x in np.log1p(g_hist)] log_b_hist = [float(x) for x in np.log1p(b_hist)] hist = { - 'r': log_r_hist, - 'g': log_g_hist, - 'b': log_b_hist, - 'r_clip': r_clip_count, - 'g_clip': g_clip_count, - 'b_clip': b_clip_count, - 'r_preclip': r_preclip_count, - 'g_preclip': g_preclip_count, - 'b_preclip': b_preclip_count, + "r": log_r_hist, + "g": log_g_hist, + "b": log_b_hist, + "r_clip": r_clip_count, + "g_clip": g_clip_count, + "b_clip": b_clip_count, + "r_preclip": r_preclip_count, + "g_preclip": g_preclip_count, + "b_preclip": b_preclip_count, } return token, hist except Exception: @@ -3555,10 +4300,10 @@ def _apply_histogram_result(self, payload): return token, hist = payload - + with self._hist_lock: self._hist_inflight = False - + if hist is not None: if token == self._hist_token: self.ui_state.histogramData = hist @@ -3566,7 +4311,7 @@ def _apply_histogram_result(self, payload): # If more updates arrived while we computed, run again soon pending = self._hist_pending is not None - + if pending: self.histogram_timer.start() @@ -3579,7 +4324,7 @@ def _kick_preview_worker(self): if self._preview_inflight: self._preview_pending = True return - + self._preview_inflight = True self._preview_pending = False self._preview_token += 1 @@ -3587,7 +4332,9 @@ def _kick_preview_worker(self): # Submit task to dedicated preview executor try: - fut = self._preview_executor.submit(self._render_preview_worker, token, self.image_editor) + fut = self._preview_executor.submit( + self._render_preview_worker, token, self.image_editor + ) fut.add_done_callback(self._on_preview_done) except RuntimeError: log.warning("Preview executor failed (shutting down?)") @@ -3613,7 +4360,7 @@ def _on_preview_done(self, fut): token, decoded = fut.result() except Exception: token, decoded = None, None - + # Emit from worker thread; Qt will queue to UI thread self.previewReady.emit((token, decoded)) @@ -3633,7 +4380,11 @@ def _apply_preview_result(self, payload): # 1. We got valid decoded data # 2. Token matches (not stale from an old request) # 3. No pending request waiting (avoid "snap back" stale frame flash) - if decoded is not None and token == self._preview_token and not self._preview_pending: + if ( + decoded is not None + and token == self._preview_token + and not self._preview_pending + ): self._last_rendered_preview = decoded self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index @@ -3656,22 +4407,20 @@ def _apply_preview_result(self, payload): # scheduling and execution, causing a spurious extra render. if should_kick: self._kick_preview_worker() - - @Slot() def cancel_crop_mode(self): - """Cancel crop mode without applying changes.""" - if self.ui_state.isCropping: - self.ui_state.isCropping = False - self.ui_state.currentCropBox = [0, 0, 1000, 1000] - # Ensure preview rotation is cleared - self.image_editor.set_edit_param("straighten_angle", 0.0) - # Force QML to refresh if it's showing provider preview frames - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.update_status_message("Crop cancelled") - log.info("Crop mode cancelled") + """Cancel crop mode without applying changes.""" + if self.ui_state.isCropping: + self.ui_state.isCropping = False + self.ui_state.currentCropBox = [0, 0, 1000, 1000] + # Ensure preview rotation is cleared + self.image_editor.set_edit_param("straighten_angle", 0.0) + # Force QML to refresh if it's showing provider preview frames + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Crop cancelled") + log.info("Crop mode cancelled") @Slot() def toggle_crop_mode(self): @@ -3681,36 +4430,38 @@ def toggle_crop_mode(self): # Reset crop box when entering crop mode self.ui_state.currentCropBox = (0, 0, 1000, 1000) # Set aspect ratios for QML dropdown - self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + self.ui_state.aspectRatioNames = [r["name"] for r in ASPECT_RATIOS] self.ui_state.currentAspectRatioIndex = 0 - + # Pre-load image into editor to ensure smooth rotation if self.image_files and self.current_index < len(self.image_files): - image_file = self.image_files[self.current_index] - filepath = image_file.path - editor_path = self.image_editor.current_filepath - - # Robust comparison - match = False - if editor_path: - try: - match = Path(editor_path).resolve() == Path(filepath).resolve() - except (OSError, ValueError): - match = str(editor_path) == str(filepath) - - if not match: - log.debug(f"toggle_crop_mode: Loading {filepath} into editor") - # Use cached preview if available to speed up using get_decoded_image(self.current_index) - # note: get_decoded_image verifies index bounds - cached_preview = self.get_decoded_image(self.current_index) - self.image_editor.load_image(str(filepath), cached_preview=cached_preview) - + image_file = self.image_files[self.current_index] + filepath = image_file.path + editor_path = self.image_editor.current_filepath + + # Robust comparison + match = False + if editor_path: + try: + match = Path(editor_path).resolve() == Path(filepath).resolve() + except (OSError, ValueError): + match = str(editor_path) == str(filepath) + + if not match: + log.debug(f"toggle_crop_mode: Loading {filepath} into editor") + # Use cached preview if available to speed up using get_decoded_image(self.current_index) + # note: get_decoded_image verifies index bounds + cached_preview = self.get_decoded_image(self.current_index) + self.image_editor.load_image( + str(filepath), cached_preview=cached_preview + ) + # Reset rotation to 0 when starting fresh crop mode self.image_editor.set_edit_param("straighten_angle", 0.0) self.update_status_message("Crop mode: Drag to select area, Enter to crop") log.info("Crop mode enabled") - else: # Exiting crop mode + else: # Exiting crop mode self.ui_state.isCropping = False self.ui_state.currentCropBox = (0, 0, 1000, 1000) self.update_status_message("Crop cancelled") @@ -3734,109 +4485,139 @@ def stack_source_raws(self): return # Extract base name and number, e.g., "PB210633" from "20251121-PB210633 stacked.JPG" - match = re.search(r'([A-Z]+)(\d+)\s+stacked\.JPG', filename, re.IGNORECASE) + match = re.search(r"([A-Z]+)(\d+)\s+stacked\.JPG", filename, re.IGNORECASE) if not match: self.update_status_message("Could not parse stacked JPG filename format.") log.error("Could not parse stacked JPG filename: %s", filename) return - base_prefix = match.group(1) # e.g., "PB" - base_number_str = match.group(2) # e.g., "210633" + base_prefix = match.group(1) # e.g., "PB" + base_number_str = match.group(2) # e.g., "210633" base_number = int(base_number_str) # Determine the RAW source directory - raw_source_dir_str = config.get('raw', 'source_dir') + raw_source_dir_str = config.get("raw", "source_dir") if not raw_source_dir_str: - self.update_status_message("RAW source directory not configured in settings.") + self.update_status_message( + "RAW source directory not configured in settings." + ) log.warning("RAW source directory (raw.source_dir) is not set in config.") return - + raw_base_dir = Path(raw_source_dir_str) if not raw_base_dir.is_dir(): - self.update_status_message(f"RAW source directory not found: {raw_base_dir}") - log.warning("Configured RAW source directory does not exist: %s", raw_base_dir) + self.update_status_message( + f"RAW source directory not found: {raw_base_dir}" + ) + log.warning( + "Configured RAW source directory does not exist: %s", raw_base_dir + ) return # Get the mirror base from config - mirror_base_str = config.get('raw', 'mirror_base') + mirror_base_str = config.get("raw", "mirror_base") if not mirror_base_str: - self.update_status_message("RAW mirror base directory not configured in settings.") + self.update_status_message( + "RAW mirror base directory not configured in settings." + ) log.warning("RAW mirror base (raw.mirror_base) is not set in config.") return - + mirror_base_dir = Path(mirror_base_str) if not mirror_base_dir.is_dir(): - self.update_status_message(f"RAW mirror base directory not found: {mirror_base_dir}") - log.warning("Configured RAW mirror base directory does not exist: %s", mirror_base_dir) + self.update_status_message( + f"RAW mirror base directory not found: {mirror_base_dir}" + ) + log.warning( + "Configured RAW mirror base directory does not exist: %s", + mirror_base_dir, + ) return # The date structure in the RAW directory mirrors the structure relative to the mirror_base try: relative_part = current_image_path.parent.relative_to(mirror_base_dir) except ValueError: - self.update_status_message("Current image is not in the configured mirror base directory.") + self.update_status_message( + "Current image is not in the configured mirror base directory." + ) log.error( "Could not find relative path for '%s' from base '%s'. Check 'mirror_base' config.", current_image_path.parent, - mirror_base_dir + mirror_base_dir, ) return raw_search_dir = raw_base_dir / relative_part - + if not raw_search_dir.is_dir(): - self.update_status_message(f"RAW directory for this date not found: {raw_search_dir}") + self.update_status_message( + f"RAW directory for this date not found: {raw_search_dir}" + ) log.warning("RAW search directory does not exist: %s", raw_search_dir) return # Find RAW files by decrementing the number found_raw_files: List[Path] = [] # Start one number less than the stacked image number - current_raw_number = base_number - 1 - + current_raw_number = base_number - 1 + # Limit to reasonable number of RAWs to avoid infinite loop or too many files - max_raw_search = 15 # As per user request, typically between 3 and 15 + max_raw_search = 15 # As per user request, typically between 3 and 15 search_count = 0 while current_raw_number >= 0 and search_count < max_raw_search: - raw_filename_stem = f"{base_prefix}{current_raw_number:06d}" # e.g., PB210632 - + raw_filename_stem = ( + f"{base_prefix}{current_raw_number:06d}" # e.g., PB210632 + ) + # Look for any of the common RAW extensions potential_raw_paths = [] for ext in RAW_EXTENSIONS: potential_raw_paths.append(raw_search_dir / f"{raw_filename_stem}{ext}") - + found_this_number = False for p in potential_raw_paths: if p.is_file(): found_raw_files.append(p) found_this_number = True break - + if not found_this_number: # User specified "continue until there is a gap in the numbers" # If we don't find any RAW for a number, assume it's a gap and stop - if found_raw_files: # Only break if we've found at least one file before this gap + if ( + found_raw_files + ): # Only break if we've found at least one file before this gap break - + current_raw_number -= 1 search_count += 1 - + if not found_raw_files: - self.update_status_message(f"No source RAW files found in {raw_search_dir} for {filename}.") + self.update_status_message( + f"No source RAW files found in {raw_search_dir} for {filename}." + ) log.info("No source RAWs found for %s in %s", filename, raw_search_dir) return # Sort the files by name to ensure Helicon Focus receives them in sequence found_raw_files.sort() - self.update_status_message(f"Launching Helicon Focus with {len(found_raw_files)} RAWs...") - log.info("Launching Helicon Focus for %s with RAWs: %s", filename, [str(p) for p in found_raw_files]) + self.update_status_message( + f"Launching Helicon Focus with {len(found_raw_files)} RAWs..." + ) + log.info( + "Launching Helicon Focus for %s with RAWs: %s", + filename, + [str(p) for p in found_raw_files], + ) success = self._launch_helicon_with_files(found_raw_files) if success: # Mark as restacked on success from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") stem = self.image_files[self.current_index].path.stem meta = self.sidecar.get_metadata(stem) @@ -3846,27 +4627,26 @@ def stack_source_raws(self): self._metadata_cache_index = (-1, -1) self.dataChanged.emit() self.sync_ui_state() - + self.update_status_message("Helicon Focus launched successfully.") else: self.update_status_message("Failed to launch Helicon Focus.") - - - @Slot() def execute_crop(self): """Execute the crop operation: crop image, save, backup, and refresh.""" if not self.image_files or self.current_index >= len(self.image_files): self.update_status_message("No image to crop") return - + if not self.ui_state.isCropping: return - + # Capture current rotation (straighten_angle) from editor state BEFORE any reload # This is the single source of truth since set_straighten_angle updates it live. - current_rotation = float(self.image_editor.current_edits.get("straighten_angle", 0.0)) + current_rotation = float( + self.image_editor.current_edits.get("straighten_angle", 0.0) + ) crop_box_raw = self.ui_state.currentCropBox @@ -3875,20 +4655,22 @@ def execute_crop(self): # Handle QJSValue/QVariant wrapper if present if hasattr(crop_box_raw, "toVariant"): crop_box_raw = crop_box_raw.toVariant() - + # Convert list to tuple if needed if isinstance(crop_box_raw, list): crop_box_raw = tuple(crop_box_raw) - + if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: - raise ValueError(f"Expected 4-item tuple, got {type(crop_box_raw)}: {crop_box_raw}") - + raise ValueError( + f"Expected 4-item tuple, got {type(crop_box_raw)}: {crop_box_raw}" + ) + # Coerce elements to int and clamp to [0, 1000] l, t, r, b = [max(0, min(1000, int(x))) for x in crop_box_raw] - + # Ensure correct order (left <= right, top <= bottom) crop_box_raw = (min(l, r), min(t, b), max(l, r), max(t, b)) - + except (ValueError, TypeError, AttributeError) as e: log.warning("Invalid crop box format: %s", e) self.update_status_message("Invalid crop selection") @@ -3901,7 +4683,7 @@ def execute_crop(self): # Ensure image is loaded in editor image_file = self.image_files[self.current_index] filepath = image_file.path - + # Robust path comparison editor_path = self.image_editor.current_filepath paths_match = False @@ -3912,18 +4694,22 @@ def execute_crop(self): paths_match = str(editor_path) == str(filepath) if not paths_match: - log.debug(f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}") + log.debug( + f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}" + ) cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image(str(filepath), cached_preview=cached_preview): + if not self.image_editor.load_image( + str(filepath), cached_preview=cached_preview + ): self.update_status_message("Failed to load image for cropping") return self.image_editor.set_crop_box(crop_box_raw) - + # Re-apply the captured rotation. # This handles cases where we reloaded the image (resetting edits) or where UI state sync was flaky. - self.image_editor.set_edit_param('straighten_angle', current_rotation) - + self.image_editor.set_edit_param("straighten_angle", current_rotation) + # Save via ImageEditor (handles rotation + crop correctly) try: save_result = self.image_editor.save_image() @@ -3935,74 +4721,84 @@ def execute_crop(self): log.exception(f"execute_crop: Unexpected error during save: {e}") self.update_status_message("Failed to save cropped image") return - + if save_result: saved_path, backup_path = save_result - + # Track for undo import time + timestamp = time.time() - self.undo_history.append(("crop", (str(saved_path), str(backup_path)), timestamp)) - + self.undo_history.append( + ("crop", (str(saved_path), str(backup_path)), timestamp) + ) + # Exit crop mode self.ui_state.isCropping = False self.ui_state.currentCropBox = (0, 0, 1000, 1000) - + # Refresh the view self.refresh_image_list() - + # Find the edited image for i, img_file in enumerate(self.image_files): if img_file.path == saved_path: self.current_index = i break - + # Invalidate cache and refresh display self.display_generation += 1 self.image_cache.clear() self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - + # Reset zoom/pan self.ui_state.resetZoomPan() - + if self.ui_state.isHistogramVisible: self.update_histogram() - + self.update_status_message("Image cropped and saved") log.info("Crop operation completed for %s", saved_path) - + # Force reload of editor to ensure subsequent edits operate on the cropped image self.image_editor.clear() self.reset_edit_parameters() - + else: self.update_status_message("Failed to save cropped image") - + @Slot() def auto_levels(self): """Calculates and applies auto levels (preview only). Returns False if skipped.""" if not self.image_files: self.update_status_message("No image to adjust") return False - + image_file = self.image_files[self.current_index] filepath = str(image_file.path) - + # Ensure image is loaded in editor - if not self.image_editor.current_filepath or str(self.image_editor.current_filepath) != filepath: + if ( + not self.image_editor.current_filepath + or str(self.image_editor.current_filepath) != filepath + ): cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image(filepath, cached_preview=cached_preview): + if not self.image_editor.load_image( + filepath, cached_preview=cached_preview + ): self.update_status_message("Failed to load image") return False # Calculate auto levels # Calculate auto levels - now returns (blacks, whites, p_low, p_high) - blacks, whites, p_low, p_high = self.image_editor.auto_levels(self.auto_level_threshold) - + blacks, whites, p_low, p_high = self.image_editor.auto_levels( + self.auto_level_threshold + ) + # Auto-strength computation using stretch-factor capping - # + # # Philosophy: threshold_percent defines acceptable clipping (e.g., 0.1% at each end). # Auto-strength should NOT prevent that clipping - it's intentional. # Instead, auto-strength prevents INSANE levels on low-dynamic-range images. @@ -4018,15 +4814,17 @@ def auto_levels(self): if dynamic_range < 1.0: # Degenerate case: nearly flat image strength = 0.0 - log.debug(f"Auto levels: degenerate dynamic range ({dynamic_range:.2f}), strength=0") + log.debug( + f"Auto levels: degenerate dynamic range ({dynamic_range:.2f}), strength=0" + ) else: stretch_full = 255.0 / dynamic_range - + # Cap stretch to prevent insane levels # E.g., if image spans only 50-200 (range=150), full stretch would be 255/150 = 1.7x (fine) # But if image spans 100-110 (range=10), full stretch would be 255/10 = 25.5x (insane!) STRETCH_CAP = 4.0 # Maximum allowed stretch factor - + if stretch_full <= STRETCH_CAP: # Reasonable stretch, use full strength strength = 1.0 @@ -4036,35 +4834,37 @@ def auto_levels(self): # solving for strength: strength = (STRETCH_CAP - 1) / (stretch_full - 1) strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) strength = max(0.0, min(1.0, strength)) - - log.debug(f"Auto levels: p_low={p_low:.1f}, p_high={p_high:.1f}, " - f"range={dynamic_range:.1f}, stretch_full={stretch_full:.2f}, strength={strength:.3f}") + + log.debug( + f"Auto levels: p_low={p_low:.1f}, p_high={p_high:.1f}, " + f"range={dynamic_range:.1f}, stretch_full={stretch_full:.2f}, strength={strength:.3f}" + ) else: strength = self.auto_level_strength # Apply strength scaling to blacks and whites parameters blacks *= strength whites *= strength - + # Apply scaled values - self.image_editor.set_edit_param('blacks', blacks) - self.image_editor.set_edit_param('whites', whites) - + self.image_editor.set_edit_param("blacks", blacks) + self.image_editor.set_edit_param("whites", whites) + # Update UI state self.ui_state.blacks = blacks self.ui_state.whites = whites - + # Trigger preview update self.ui_state.currentImageSourceChanged.emit() - + if self.ui_state.isHistogramVisible: self.update_histogram() - + # Determine status message based on whether endpoints were pinned (clipping detected) # We check p_high/p_low directly because whites/blacks might be small due to strength scaling # even if not pinned. msg = "Auto levels applied" - + # Check for essentially no-op (degenerate or already full range) # Degenerate: dynamic range is tiny (< 1.0) # Full range: p_low is near 0 and p_high near 255 @@ -4078,12 +4878,17 @@ def auto_levels(self): msg = "Auto levels: highlights already clipped; only adjusting shadows" elif p_low <= 0.0: msg = "Auto levels: shadows already clipped; only adjusting highlights" - + self._kick_preview_worker() self.update_status_message(f"{msg} (preview only)") - log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s", - filepath, self.auto_level_threshold, strength, msg) + log.info( + "Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s", + filepath, + self.auto_level_threshold, + strength, + msg, + ) return True @Slot() @@ -4095,7 +4900,7 @@ def quick_auto_levels(self): # Apply the preview first (loads image + sets params) applied = self.auto_levels() - + # If in auto mode and no changes were made (skipped), don't save if self.auto_level_strength_auto and not applied: # Status message already set by auto_levels ("No changes made...") @@ -4103,6 +4908,7 @@ def quick_auto_levels(self): # Save import time + try: save_result = self.image_editor.save_image() except RuntimeError as e: @@ -4117,68 +4923,77 @@ def quick_auto_levels(self): if save_result: saved_path, backup_path = save_result timestamp = time.time() - self.undo_history.append(("auto_levels", (saved_path, backup_path), timestamp)) - + self.undo_history.append( + ("auto_levels", (saved_path, backup_path), timestamp) + ) + # Force reload to ensure disk consistency self.image_editor.clear() - + # Refresh list/cache/UI (standard save pattern) - # Note: We must locate the saved_path again because the list order + # Note: We must locate the saved_path again because the list order # might have changed (e.g., if a backup file was inserted before it). self.refresh_image_list() - + # Find image again using robust path matching new_index = -1 target_name = Path(saved_path).name - + for i, img_file in enumerate(self.image_files): # Match by filename alone - safest for flat directory structures # avoiding drive letter/symlink/casing issues with full paths if img_file.path.name == target_name: new_index = i break - + if new_index != -1: self.current_index = new_index else: - log.warning("Auto levels: Could not find saved image %s (name: %s) in refreshed list", saved_path, target_name) - + log.warning( + "Auto levels: Could not find saved image %s (name: %s) in refreshed list", + saved_path, + target_name, + ) + self.display_generation += 1 self.image_cache.clear() self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - + if self.ui_state.isHistogramVisible: self.update_histogram() - + self.update_status_message("Auto levels applied and saved") - log.info("Quick auto levels saved for %s. New index: %d", saved_path, self.current_index) + log.info( + "Quick auto levels saved for %s. New index: %d", + saved_path, + self.current_index, + ) else: self.update_status_message("Failed to save image") - - @Slot() def quick_auto_white_balance(self): """Quickly apply auto white balance, save the image, and track for undo.""" if not self.image_files: self.update_status_message("No image to adjust") return - + import time + image_file = self.image_files[self.current_index] filepath = str(image_file.path) - + # Load the image into the editor if not already loaded cached_preview = self.get_decoded_image(self.current_index) if not self.image_editor.load_image(filepath, cached_preview=cached_preview): self.update_status_message("Failed to load image") return - + # Calculate and apply auto white balance self.auto_white_balance() - + # Save the edited image (this creates a backup automatically) try: save_result = self.image_editor.save_image() @@ -4187,7 +5002,9 @@ def quick_auto_white_balance(self): self.update_status_message(f"Failed to save image: {e}") return except Exception as e: - log.exception(f"quick_auto_white_balance: Unexpected error during save: {e}") + log.exception( + f"quick_auto_white_balance: Unexpected error during save: {e}" + ) self.update_status_message("Failed to save image") return @@ -4195,21 +5012,23 @@ def quick_auto_white_balance(self): saved_path, backup_path = save_result # Track this action for undo timestamp = time.time() - self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) - + self.undo_history.append( + ("auto_white_balance", (saved_path, backup_path), timestamp) + ) + # Force the image editor to clear its current state so it reloads fresh self.image_editor.clear() - + # Refresh the view - need to refresh image list since backup file was created original_path = Path(filepath) self.refresh_image_list() - + # Find the edited image (not the backup) in the refreshed list for i, img_file in enumerate(self.image_files): if img_file.path == original_path: self.current_index = i break - + # Invalidate cache for the edited image so it's reloaded from disk # This ensures the Image Editor will see the updated version self.display_generation += 1 @@ -4217,26 +5036,26 @@ def quick_auto_white_balance(self): self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - + # Update histogram if visible if self.ui_state.isHistogramVisible: self.update_histogram() - + self.update_status_message("Auto white balance applied and saved") log.info("Quick auto white balance applied to %s", filepath) else: self.update_status_message("Failed to save image") - + @Slot() def auto_white_balance(self): """ Dispatcher for auto white balance. Calls the appropriate method based on the mode set in the config ('lab' or 'rgb'). """ - mode = config.get('awb', 'mode', fallback='lab') - if mode == 'lab': + mode = config.get("awb", "mode", fallback="lab") + if mode == "lab": self.auto_white_balance_lab() - elif mode == 'rgb': + elif mode == "rgb": self.auto_white_balance_legacy() else: log.error(f"Unknown AWB mode: {mode}") @@ -4250,48 +5069,47 @@ def auto_white_balance_legacy(self): if not self.image_editor.original_image: log.warning("No image loaded in editor for auto white balance") return - + try: import numpy as np except ImportError: log.error("NumPy not found. Please install with: pip install numpy") self.update_status_message("Error: NumPy not installed") return - + log.info("Applying legacy (RGB Grey World) Auto White Balance") - + img = self.image_editor.original_image arr = np.array(img, dtype=np.float32) - + r_mean = arr[:, :, 0].mean() g_mean = arr[:, :, 1].mean() b_mean = arr[:, :, 2].mean() - + grey_target = (r_mean + g_mean + b_mean) / 3.0 - + r_diff = r_mean - grey_target g_diff = g_mean - grey_target - + by_shift = -(r_diff + g_diff) / 2.0 mg_shift = -(r_diff - g_diff) / 2.0 - + by_value = by_shift / 63.75 mg_value = mg_shift / 63.75 - + by_value = float(np.clip(by_value, -1.0, 1.0)) mg_value = float(np.clip(mg_value, -1.0, 1.0)) - - self.image_editor.set_edit_param('white_balance_by', by_value) - self.image_editor.set_edit_param('white_balance_mg', mg_value) - + + self.image_editor.set_edit_param("white_balance_by", by_value) + self.image_editor.set_edit_param("white_balance_mg", mg_value) + self.ui_state.white_balance_by = by_value self.ui_state.white_balance_mg = mg_value - + self.ui_refresh_generation += 1 self.ui_state.currentImageSourceChanged.emit() self.update_status_message("Auto white balance applied (Legacy)") - def auto_white_balance_lab(self): """ Calculates and applies auto white balance using the Lab color space, @@ -4300,84 +5118,96 @@ def auto_white_balance_lab(self): if not self.image_editor.original_image: log.warning("No image loaded in editor for auto white balance") return - + try: import cv2 import numpy as np except ImportError: - log.error("OpenCV or NumPy not found. Please install with: pip install opencv-python numpy") + log.error( + "OpenCV or NumPy not found. Please install with: pip install opencv-python numpy" + ) self.update_status_message("Error: OpenCV or NumPy not installed") return img = self.image_editor.original_image # Ensure image is RGB before processing - if img.mode != 'RGB': - img = img.convert('RGB') - + if img.mode != "RGB": + img = img.convert("RGB") + arr = np.array(img, dtype=np.uint8) # --- Tunable Constants for Auto White Balance (from config) --- - _LOWER_BOUND_RGB = config.getint('awb', 'rgb_lower_bound', 5) - _UPPER_BOUND_RGB = config.getint('awb', 'rgb_upper_bound', 250) - _LUMA_LOWER_BOUND = config.getint('awb', 'luma_lower_bound', 30) - _LUMA_UPPER_BOUND = config.getint('awb', 'luma_upper_bound', 220) - warm_bias = config.getint('awb', 'warm_bias', 6) - tint_bias = config.getint('awb', 'tint_bias', 0) + _LOWER_BOUND_RGB = config.getint("awb", "rgb_lower_bound", 5) + _UPPER_BOUND_RGB = config.getint("awb", "rgb_upper_bound", 250) + _LUMA_LOWER_BOUND = config.getint("awb", "luma_lower_bound", 30) + _LUMA_UPPER_BOUND = config.getint("awb", "luma_upper_bound", 220) + warm_bias = config.getint("awb", "warm_bias", 6) + tint_bias = config.getint("awb", "tint_bias", 0) _TARGET_A_LAB = 128.0 + tint_bias _TARGET_B_LAB = 128.0 + warm_bias - _SCALING_FACTOR_LAB_TO_SLIDER = 128.0 - _CORRECTION_STRENGTH = config.getfloat('awb', 'strength', 0.7) + _SCALING_FACTOR_LAB_TO_SLIDER = 128.0 + _CORRECTION_STRENGTH = config.getfloat("awb", "strength", 0.7) # --- 1. Reject clipped channels and use a luma midtone mask --- mask = ( - (arr[:, :, 0] > _LOWER_BOUND_RGB) & (arr[:, :, 0] < _UPPER_BOUND_RGB) & - (arr[:, :, 1] > _LOWER_BOUND_RGB) & (arr[:, :, 1] < _UPPER_BOUND_RGB) & - (arr[:, :, 2] > _LOWER_BOUND_RGB) & (arr[:, :, 2] < _UPPER_BOUND_RGB) + (arr[:, :, 0] > _LOWER_BOUND_RGB) + & (arr[:, :, 0] < _UPPER_BOUND_RGB) + & (arr[:, :, 1] > _LOWER_BOUND_RGB) + & (arr[:, :, 1] < _UPPER_BOUND_RGB) + & (arr[:, :, 2] > _LOWER_BOUND_RGB) + & (arr[:, :, 2] < _UPPER_BOUND_RGB) ) - - luma = (0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2]) + + luma = 0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2] mask &= (luma > _LUMA_LOWER_BOUND) & (luma < _LUMA_UPPER_BOUND) - + if not np.any(mask): - log.warning("Auto white balance: No pixels found after clipping and luma filter. Aborting.") + log.warning( + "Auto white balance: No pixels found after clipping and luma filter. Aborting." + ) self.update_status_message("AWB failed: no valid pixels found") return # --- 2. Work in Lab color space --- lab_image = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB) - + a_channel = lab_image[:, :, 1] b_channel = lab_image[:, :, 2] masked_a = a_channel[mask] masked_b = b_channel[mask] - + a_mean = masked_a.mean() b_mean = masked_b.mean() a_shift = _TARGET_A_LAB - a_mean b_shift = _TARGET_B_LAB - b_mean - + log.info( "Auto WB (Lab) - means: a*=%.1f, b*=%.1f; targets: a*=%.1f, b*=%.1f; shifts: a*=%.1f, b*=%.1f", - a_mean, b_mean, _TARGET_A_LAB, _TARGET_B_LAB, a_shift, b_shift + a_mean, + b_mean, + _TARGET_A_LAB, + _TARGET_B_LAB, + a_shift, + b_shift, ) # --- 3. Convert Lab shift to our slider values with strength factor --- by_value = (b_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH mg_value = (a_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH - + by_value = float(np.clip(by_value, -1.0, 1.0)) mg_value = float(np.clip(mg_value, -1.0, 1.0)) - + log.info(f"Auto white balance values: B/Y={by_value:.3f}, M/G={mg_value:.3f}") - - self.image_editor.set_edit_param('white_balance_by', by_value) - self.image_editor.set_edit_param('white_balance_mg', mg_value) - + + self.image_editor.set_edit_param("white_balance_by", by_value) + self.image_editor.set_edit_param("white_balance_mg", mg_value) + self.ui_state.white_balance_by = by_value self.ui_state.white_balance_mg = mg_value - + self.ui_refresh_generation += 1 self.ui_state.currentImageSourceChanged.emit() self.update_status_message("Auto white balance applied") @@ -4388,13 +5218,17 @@ def _get_stack_info(self, index: int) -> str: if start <= index <= end: count_in_stack = end - start + 1 pos_in_stack = index - start + 1 - info = f"Stack {i+1} ({pos_in_stack}/{count_in_stack})" + info = f"Stack {i + 1} ({pos_in_stack}/{count_in_stack})" break - if not info and self.stack_start_index is not None and self.stack_start_index == index: + if ( + not info + and self.stack_start_index is not None + and self.stack_start_index == index + ): info = "Stack Start Marked" log.debug("_get_stack_info for index %d: %s", index, info) return info - + def _get_batch_info(self, index: int) -> str: """Get batch info for the given index.""" info = "" @@ -4404,14 +5238,14 @@ def _get_batch_info(self, index: int) -> str: if start <= index <= end: in_batch = True break - + if in_batch: # Calculate total count across all batches total_count = sum(end - start + 1 for start, end in self.batches) info = f"{total_count} in Batch" elif self.batch_start_index is not None and self.batch_start_index == index: info = "Batch Start Marked" - + log.debug("_get_batch_info for index %d: %s", index, info) return info @@ -4420,7 +5254,7 @@ def get_stack_summary(self) -> str: return "No stacks defined." summary = [] for i, (start, end) in enumerate(self.stacks): - summary.append(f"Stack {i+1}: {start}-{end}") + summary.append(f"Stack {i + 1}: {start}-{end}") return "; ".join(summary) def is_stacked(self) -> bool: @@ -4439,11 +5273,50 @@ def _update_cache_stats(self): size_mb = self.image_cache.currsize / (1024 * 1024) self.ui_state.cacheStats = f"Cache: {hits} hits, {misses} misses ({hit_rate:.1f}%), {size_mb:.1f} MB" + def get_recycle_bin_stats(self) -> List[Dict[str, Any]]: + """Get stats for all tracked recycle bins. + + Returns: + List of dicts: [{"path": absolute_path, "count": num_files}, ...] + """ + stats = [] + # Filter out bins that don't exist anymore + active_bins = {p for p in self.active_recycle_bins if p.exists() and p.is_dir()} + self.active_recycle_bins = active_bins + + for bin_path in self.active_recycle_bins: + try: + # Count files + count = sum(1 for p in bin_path.iterdir() if p.is_file()) + if count > 0: + stats.append({"path": str(bin_path), "count": count}) + except OSError: + continue + + return stats + + def cleanup_recycle_bins(self): + """Delete all tracked recycle bins.""" + active_bins = {p for p in self.active_recycle_bins if p.exists() and p.is_dir()} + + for bin_path in active_bins: + try: + shutil.rmtree(bin_path) + log.info("Cleaned up recycle bin: %s", bin_path) + except OSError as e: + log.error("Failed to delete recycle bin %s: %s", bin_path, e) + + self.active_recycle_bins.clear() + + # Clear stats cache since we deleted files/folders + clear_raw_count_cache() + + def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): """FastStack Application Entry Point""" global _debug_mode _debug_mode = debug - + t0 = time.perf_counter() setup_logging(debug) if debug: @@ -4453,15 +5326,21 @@ def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") - app = QApplication(sys.argv) # QApplication is correct for desktop apps with widgets + app = QApplication( + sys.argv + ) # QApplication is correct for desktop apps with widgets if debug: log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) if not image_dir: - image_dir_str = config.get('core', 'default_directory') + image_dir_str = config.get("core", "default_directory") if not image_dir_str: - log.warning("No image directory provided and no default directory set. Opening directory selection dialog.") - selected_dir = QFileDialog.getExistingDirectory(None, "Select Image Directory") + log.warning( + "No image directory provided and no default directory set. Opening directory selection dialog." + ) + selected_dir = QFileDialog.getExistingDirectory( + None, "Select Image Directory" + ) if not selected_dir: log.error("No image directory selected. Exiting.") sys.exit(1) @@ -4482,7 +5361,9 @@ def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): engine.addImportPath("qrc:/qt-project.org/imports") engine.addImportPath(os.path.join(os.path.dirname(__file__), "qml")) # Add the path to Qt5Compat.GraphicalEffects to QML import paths - engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat")) + engine.addImportPath( + os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat") + ) controller = AppController(image_dir_path, engine, debug_cache=debug_cache) if debug: @@ -4522,14 +5403,26 @@ def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): sys.exit(app.exec()) + def cli(): """CLI entry point.""" - parser = argparse.ArgumentParser(description="FastStack - Ultra-fast JPG Viewer for Focus Stacking Selection") - parser.add_argument("image_dir", nargs="?", default="", help="Directory of images to view") - parser.add_argument("--debug", action="store_true", help="Enable debug logging and timing information") - parser.add_argument("--debugcache", action="store_true", help="Enable debug cache features") + parser = argparse.ArgumentParser( + description="FastStack - Ultra-fast JPG Viewer for Focus Stacking Selection" + ) + parser.add_argument( + "image_dir", nargs="?", default="", help="Directory of images to view" + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable debug logging and timing information", + ) + parser.add_argument( + "--debugcache", action="store_true", help="Enable debug cache features" + ) args = parser.parse_args() main(image_dir=args.image_dir, debug=args.debug, debug_cache=args.debugcache) + if __name__ == "__main__": cli() diff --git a/faststack/check_scipy.py b/faststack/check_scipy.py index a8506a2..f1df113 100644 --- a/faststack/check_scipy.py +++ b/faststack/check_scipy.py @@ -1,6 +1,6 @@ - try: import scipy.ndimage + print("scipy available") except ImportError: print("scipy NOT available") diff --git a/faststack/config.py b/faststack/config.py index 7c273ae..2dbc6fc 100644 --- a/faststack/config.py +++ b/faststack/config.py @@ -6,7 +6,7 @@ import glob import os import re -from pathlib import Path, PureWindowsPath +from pathlib import PureWindowsPath from faststack.logging_setup import get_app_data_dir @@ -23,9 +23,9 @@ def detect_rawtherapee_path(): # Finds paths like C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe base_patterns = [ r"C:\Program Files\RawTherapee*\**\rawtherapee-cli.exe", - r"C:\Program Files (x86)\RawTherapee*\**\rawtherapee-cli.exe" + r"C:\Program Files (x86)\RawTherapee*\**\rawtherapee-cli.exe", ] - + try: matches = [] for pattern in base_patterns: @@ -33,12 +33,12 @@ def detect_rawtherapee_path(): if not matches: return None - + # Helper to extract version numbers for natural sorting # e.g., "5.10" -> [5, 10] def version_sort_key(path): for part in reversed(PureWindowsPath(path).parts): - if re.fullmatch(r'\d+(?:\.\d+)*', part): + if re.fullmatch(r"\d+(?:\.\d+)*", part): return [int(n) for n in part.split(".")] return [0] @@ -67,7 +67,6 @@ def version_sort_key(path): "theme": "dark", "default_directory": "", "optimize_for": "speed", # "speed" or "quality" - # --- Auto Levels Configuration --- # # Behavior: @@ -80,7 +79,7 @@ def version_sort_key(path): # 2. Construct a levels transform to map these points to 0 and 255. # 3. Blend the transformed image with the original using `auto_level_strength`. # 4. If `auto_level_strength_auto` is True, `auto_level_strength` acts as a maximum; - # the system will automatically reduce the applied strength if the computed + # the system will automatically reduce the applied strength if the computed # transform would cause excessive clipping or color instability. # # Practical Tuning: @@ -89,7 +88,6 @@ def version_sort_key(path): # Lower values (e.g. 0.001 = 0.1%) are gentler and preserve more dynamic range. # - auto_level_strength: 1.0 applies the full mathematical correction. Lower values # blend the result for a subtler effect. - "auto_level_threshold": "0.1", "auto_level_strength": "1.0", "auto_level_strength_auto": "False", @@ -108,7 +106,7 @@ def version_sort_key(path): "monitor_icc_path": "", # For 'icc' mode: path to monitor ICC profile }, "awb": { - "mode": "lab", # "lab" or "rgb" + "mode": "lab", # "lab" or "rgb" "strength": "0.7", "warm_bias": "6", "tint_bias": "0", @@ -124,9 +122,10 @@ def version_sort_key(path): "raw": { "source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos", "mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom", - } + }, } + class AppConfig: def __init__(self): self.config_path = get_app_data_dir() / "faststack.ini" @@ -149,20 +148,21 @@ def load(self): for key, value in keys.items(): if not self.config.has_option(section, key): self.config.set(section, key, value) - self.save() # Save to add any missing keys + self.save() # Save to add any missing keys # Validate RawTherapee path (re-detect if missing) if sys.platform == "win32": current_rt_path = self.get("rawtherapee", "exe") if not os.path.exists(current_rt_path): - log.warning(f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection...") + log.warning( + f"Configured RawTherapee path not found: {current_rt_path}. Attempting re-detection..." + ) new_path = detect_rawtherapee_path() if new_path and new_path != current_rt_path: log.info(f"Found new RawTherapee path: {new_path}") self.set("rawtherapee", "exe", new_path) self.save() - def save(self): """Saves the current configuration to the INI file.""" try: @@ -190,5 +190,6 @@ def set(self, section, key, value): self.config.add_section(section) self.config.set(section, key, str(value)) + # Global config instance config = AppConfig() diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py index 4ec5ee9..40b6128 100644 --- a/faststack/imaging/cache.py +++ b/faststack/imaging/cache.py @@ -40,9 +40,7 @@ def __setitem__(self, key, value): # Before adding a new item, we might need to evict others # This is handled by the parent class, which will call popitem if needed super().__setitem__(key, value) - log.debug( - f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB" - ) + log.debug(f"Cached item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB") def popitem(self): """Extend popitem to log eviction.""" @@ -86,7 +84,9 @@ def get_decoded_image_size(item) -> int: bytes_per_pixel = getattr(item, "channels", 4) # Default to RGBA return item.width * item.height * bytes_per_pixel - log.warning(f"Unexpected item type in cache: {type(item)}. Returning estimated size of 1.") + log.warning( + f"Unexpected item type in cache: {type(item)}. Returning estimated size of 1." + ) return 1 # Should not happen diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index 47ccc9e..3b0e83c 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -1,14 +1,12 @@ import logging import os import shutil -import glob import re import math from pathlib import Path from typing import Optional, Dict, Any, Tuple import numpy as np -from PIL import Image, ImageEnhance, ImageFilter, ImageOps, ExifTags -from io import BytesIO +from PIL import Image, ImageFilter, ImageOps, ExifTags from faststack.models import DecodedImage @@ -29,7 +27,7 @@ except ImportError: QImage = None -from faststack.imaging.optional_deps import cv2, HAS_OPENCV +from faststack.imaging.optional_deps import cv2 import threading @@ -57,7 +55,7 @@ def sanitize_exif_orientation(exif_bytes: bytes | None) -> bytes | None: exif = Image.Exif() exif.load(exif_bytes) # Pillow 9.1.0+ has ExifTags.Base.Orientation, fallback to 0x0112 if needed - orientation_tag = getattr(ExifTags.Base, 'Orientation', 0x0112) + orientation_tag = getattr(ExifTags.Base, "Orientation", 0x0112) exif[orientation_tag] = 1 return exif.tobytes() except Exception: @@ -65,7 +63,6 @@ def sanitize_exif_orientation(exif_bytes: bytes | None) -> bytes | None: return None - def create_backup_file(original_path: Path) -> Optional[Path]: """ Creates a backup of the original file with naming pattern: @@ -145,7 +142,7 @@ def _gaussian_blur_float(arr: np.ndarray, radius: float) -> np.ndarray: try: h, w, c = arr.shape blurred_channels = [] - + # Process each channel independently for i in range(c): ch_data = arr[:, :, i] @@ -154,21 +151,25 @@ def _gaussian_blur_float(arr: np.ndarray, radius: float) -> np.ndarray: mx = max(1.0, float(ch_data.max())) mn = min(0.0, float(ch_data.min())) scale = mx - mn - + if scale > 0: ch_u8 = ((ch_data - mn) / scale * 255).astype(np.uint8) - ch_img = Image.fromarray(ch_u8, mode='L') + ch_img = Image.fromarray(ch_u8, mode="L") # Pillow's GaussianBlur radius is roughly comparable to OpenCV sigma - blurred_ch_img = ch_img.filter(ImageFilter.GaussianBlur(radius=radius)) + blurred_ch_img = ch_img.filter( + ImageFilter.GaussianBlur(radius=radius) + ) # Scale back to original float range - blurred_ch = np.array(blurred_ch_img).astype(np.float32) / 255.0 * scale + mn + blurred_ch = ( + np.array(blurred_ch_img).astype(np.float32) / 255.0 * scale + mn + ) blurred_channels.append(blurred_ch) else: blurred_channels.append(ch_data.copy()) - + # Stack back into (H, W, C) return np.stack(blurred_channels, axis=-1) - + except Exception as e: log.warning(f"Fallback blur failed: {e}") return arr @@ -526,7 +527,9 @@ def load_image( # The cached_preview is also "cooked" (has Color Management / Saturation applied). # We use it for the VERY FIRST frame for fast display, then immediately # re-render from the master float_image in the background. - log.debug("Using cached preview (assumed orientation-correct from prefetcher)") + log.debug( + "Using cached preview (assumed orientation-correct from prefetcher)" + ) loaded_float_preview = preview_arr.astype(np.float32) / 255.0 else: @@ -755,7 +758,7 @@ def _apply_edits( # Capture pre-exposure linear state for "True Headroom" calculation pre_exposure_linear_stride = None if should_analyze: - pre_exposure_linear_stride = arr[::4, ::4, :] + pre_exposure_linear_stride = arr[::4, ::4, :] # 6. Exposure (Linear Gain for True Headroom) exposure = edits.get("exposure", 0.0) @@ -772,12 +775,15 @@ def _apply_edits( if should_analyze: # Check cache for analysis state to avoid expensive re-computation on downstream edits upstream_hash = self._get_upstream_edits_hash(edits) - + cached_analysis = None with self._lock: - if self._cached_highlight_analysis and self._cached_highlight_analysis['hash'] == upstream_hash: - cached_analysis = self._cached_highlight_analysis['state'] - + if ( + self._cached_highlight_analysis + and self._cached_highlight_analysis["hash"] == upstream_hash + ): + cached_analysis = self._cached_highlight_analysis["state"] + if cached_analysis: analysis_state = cached_analysis else: @@ -787,15 +793,15 @@ def _apply_edits( # Pass pre_exposure_linear_stride to measure "True Headroom" before exposure boost # arr_linear_stride is "Current State" (Post-WB, Post-Exposure) analysis_state = _analyze_highlight_state( - arr_linear_stride, - srgb_u8=srgb_u8_stride, # Source (Pre-Edit) State - pre_exposure_linear=pre_exposure_linear_stride + arr_linear_stride, + srgb_u8=srgb_u8_stride, # Source (Pre-Edit) State + pre_exposure_linear=pre_exposure_linear_stride, ) - + with self._lock: self._cached_highlight_analysis = { - 'hash': upstream_hash, - 'state': analysis_state + "hash": upstream_hash, + "state": analysis_state, } if not for_export: @@ -853,7 +859,11 @@ def _apply_edits( with self._lock: cached = self._cached_detail_bands # Verify both hash AND frozen values to avoid collisions - if cached and cached.get("hash") == detail_hash and cached.get("frozen") == detail_frozen: + if ( + cached + and cached.get("hash") == detail_hash + and cached.get("frozen") == detail_frozen + ): Y20_cached = cached.get("Y20") Y3_cached = cached.get("Y3") Y1_cached = cached.get("Y1") @@ -866,7 +876,11 @@ def _apply_edits( # exposure and detail bands, this scaling is APPROXIMATE when h/s is active. # The approximation is good enough for smooth 60fps dragging; exact render # happens when upstream params (WB/crop/rotate) change and cache invalidates. - exp_scale = current_exp_gain / cached_exp_gain if cache_hit and abs(cached_exp_gain) > 1e-9 else 1.0 + exp_scale = ( + current_exp_gain / cached_exp_gain + if cache_hit and abs(cached_exp_gain) > 1e-9 + else 1.0 + ) # Safe extraction: use [..., 0] if 3D, else keep as-is (avoids squeeze() collapsing H/W) def _extract_2d(blur_result): @@ -913,7 +927,11 @@ def _extract_2d(blur_result): "Y1": Y1_cached, } # Add newly computed blurs (they're at current_exp_gain, need to rescale to cached_exp_gain) - rescale_to_cached = cached_exp_gain / current_exp_gain if abs(current_exp_gain) > 1e-9 else 1.0 + rescale_to_cached = ( + cached_exp_gain / current_exp_gain + if abs(current_exp_gain) > 1e-9 + else 1.0 + ) for key, val in newly_computed.items(): if val is not None: new_cache[key] = val * rescale_to_cached @@ -1371,7 +1389,7 @@ def _apply_highlights_shadows( # Re-compute locally if not provided # We assume srgb_u8_stride is ALREADY STRIDED if passed (based on the name change) arr_stride = arr[::4, ::4, :] - # If srgb_u8_stride was passed, use it directly (it's already small). + # If srgb_u8_stride was passed, use it directly (it's already small). # If it wasn't passed, we can't easily recreate the source state here without the original source buffer. # But the caller (_apply_edits) usually provides it. state = _analyze_highlight_state(arr_stride, srgb_u8=srgb_u8_stride) @@ -1555,7 +1573,7 @@ def _get_sanitized_exif_bytes(self) -> Optional[bytes]: Prefers cached source EXIF (from paired JPEG) if available, otherwise falls back to the current original_image's EXIF. - If sanitization or serialization fails, returns None (drops EXIF) + If sanitization or serialization fails, returns None (drops EXIF) to prevent incorrect "double rotation" in viewers. Returns: @@ -1599,13 +1617,17 @@ def _get_sanitized_exif_bytes(self) -> Optional[bytes]: # 5. Guard for tobytes() if not hasattr(exif, "tobytes"): - log.warning("EXIF object has no tobytes() method, dropping EXIF to prevent rotation issues.") + log.warning( + "EXIF object has no tobytes() method, dropping EXIF to prevent rotation issues." + ) return None try: return exif.tobytes() except Exception as e: - log.warning(f"Failed to serialize sanitized EXIF: {e}. Dropping EXIF to prevent rotation issues.") + log.warning( + f"Failed to serialize sanitized EXIF: {e}. Dropping EXIF to prevent rotation issues." + ) return None except Exception as e: log.warning(f"Failed to sanitize EXIF orientation: {e}. Dropping EXIF.") diff --git a/faststack/imaging/jpeg.py b/faststack/imaging/jpeg.py index 0d2cd4a..aa9f58d 100644 --- a/faststack/imaging/jpeg.py +++ b/faststack/imaging/jpeg.py @@ -46,6 +46,7 @@ def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.nd # Fallback to Pillow try: from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") return np.array(img) except Exception as e: @@ -54,8 +55,7 @@ def decode_jpeg_rgb(jpeg_bytes: bytes, fast_dct: bool = False) -> Optional[np.nd def decode_jpeg_thumb_rgb( - jpeg_bytes: bytes, - max_dim: int = 256 + jpeg_bytes: bytes, max_dim: int = 256 ) -> Optional[np.ndarray]: """Decodes a JPEG into a thumbnail-sized RGB numpy array.""" if TURBO_AVAILABLE and jpeg_decoder: @@ -78,11 +78,14 @@ def decode_jpeg_thumb_rgb( return np.array(img) return decoded except Exception as e: - log.exception(f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow.") + log.exception( + f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow." + ) # Fallback to Pillow try: from io import BytesIO + img = Image.open(BytesIO(jpeg_bytes)) img.thumbnail((max_dim, max_dim)) return np.array(img.convert("RGB")) @@ -91,7 +94,9 @@ def decode_jpeg_thumb_rgb( return None -def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Optional[Tuple[int, int]]: +def _get_turbojpeg_scaling_factor( + width: int, height: int, max_dim: int +) -> Optional[Tuple[int, int]]: """Finds the best libjpeg-turbo scaling factor to get a thumbnail <= max_dim.""" if not TURBO_AVAILABLE or not jpeg_decoder: return None @@ -106,7 +111,7 @@ def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Opti for num, den in supported_factors: if (width * num / den) <= max_dim and (height * num / den) <= max_dim: return (num, den) - + # If no suitable factor is found, return the smallest one return supported_factors[-1] if supported_factors else None @@ -132,7 +137,7 @@ def decode_jpeg_resized( max_dim = height scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) - + if scale_factor: flags = 0 if fast_dct: @@ -140,15 +145,16 @@ def decode_jpeg_resized( flags |= 2048 decoded = jpeg_decoder.decode( - jpeg_bytes, + jpeg_bytes, scaling_factor=scale_factor, - pixel_format=TJPF_RGB, - flags=flags # Proper color space handling + pixel_format=TJPF_RGB, + flags=flags, # Proper color space handling ) - + # Only use Pillow for final resize if needed if decoded.shape[0] > height or decoded.shape[1] > width: from io import BytesIO + img = Image.fromarray(decoded) # Use BILINEAR for speed img.thumbnail((width, height), Image.Resampling.BILINEAR) @@ -156,15 +162,15 @@ def decode_jpeg_resized( return decoded except Exception as e: log.exception(f"PyTurboJPEG failed: {e}") - + # Fallback to Pillow (existing code) try: from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)) + img = Image.open(BytesIO(jpeg_bytes)) if width <= 0 or height <= 0: - return np.array(img.convert("RGB")) + return np.array(img.convert("RGB")) scale_factor_ratio = min(img.width / width, img.height / height) @@ -172,7 +178,9 @@ def decode_jpeg_resized( if scale_factor_ratio > 4: resampling = Image.Resampling.BILINEAR # Much faster else: - resampling = Image.Resampling.LANCZOS # Higher quality for smaller downscales + resampling = ( + Image.Resampling.LANCZOS + ) # Higher quality for smaller downscales img.thumbnail((width, height), resampling) return np.array(img.convert("RGB")) diff --git a/faststack/imaging/math_utils.py b/faststack/imaging/math_utils.py index 414cab6..4b52a98 100644 --- a/faststack/imaging/math_utils.py +++ b/faststack/imaging/math_utils.py @@ -1,13 +1,14 @@ import numpy as np -from typing import Optional, Dict +from typing import Optional # ---------------------------- # sRGB ↔ Linear Conversion Helpers # ---------------------------- + def _srgb_to_linear(x: np.ndarray) -> np.ndarray: """Convert sRGB values to linear light. - + Preserves headroom (values > 1.0) for highlight recovery. Clamps negatives to 0 since the power function requires non-negative input. """ @@ -26,9 +27,6 @@ def _linear_to_srgb(x: np.ndarray) -> np.ndarray: return np.where(x <= 0.0031308, 12.92 * x, (1.0 + a) * (x ** (1.0 / 2.4)) - a) - - - def _smoothstep01(x: np.ndarray) -> np.ndarray: """Hermite smoothstep: 0 at x<=0, 1 at x>=1, smooth S-curve between.""" x = np.clip(x, 0.0, 1.0) @@ -37,11 +35,11 @@ def _smoothstep01(x: np.ndarray) -> np.ndarray: def _apply_headroom_shoulder(x: np.ndarray, max_overshoot: float = 0.05) -> np.ndarray: """Compress values above 1.0 smoothly into a very small headroom. - + Maps headroom (x > 1.0) into [1.0, 1.0 + max_overshoot). Asymptotes to 1.0 + max_overshoot as x -> inf. Maintains continuity and monotonicity at 1.0. - + Args: x: Float32 array in linear light, may have values > 1.0 max_overshoot: Maximum amount to overshoot 1.0 (e.g. 0.05 means max 1.05) @@ -49,7 +47,7 @@ def _apply_headroom_shoulder(x: np.ndarray, max_overshoot: float = 0.05) -> np.n mask = x > 1.0 if not np.any(mask): return x - + out = x.copy() excess = x[mask] - 1.0 # Rational compression targeting asymptote of 'max_overshoot' @@ -72,14 +70,18 @@ def _apply_headroom_shoulder(x: np.ndarray, max_overshoot: float = 0.05) -> np.n _LINEAR_THRESHOLD_254 = ((254.0 / 255.0 + 0.055) / 1.055) ** 2.4 # ~0.972 -def _analyze_highlight_state(rgb_linear: np.ndarray, srgb_u8: Optional[np.ndarray] = None, pre_exposure_linear: Optional[np.ndarray] = None) -> dict: +def _analyze_highlight_state( + rgb_linear: np.ndarray, + srgb_u8: Optional[np.ndarray] = None, + pre_exposure_linear: Optional[np.ndarray] = None, +) -> dict: """Analyze image for headroom and clipping to tune recovery parameters. - + Args: rgb_linear: Float32 RGB array in linear light (post-exposure/WB) srgb_u8: Optional uint8 sRGB array (source image) for accurate JPEG clipping detection. MUST have same H×W dimensions as rgb_linear (or be stride-compatible). - + Returns: Dict with: - headroom_pct: Fraction of pixels with max(rgb) > 1.0 (current state recoverable data) @@ -91,13 +93,13 @@ def _analyze_highlight_state(rgb_linear: np.ndarray, srgb_u8: Optional[np.ndarra total_pixels = rgb_linear.shape[0] * rgb_linear.shape[1] if total_pixels == 0: return { - 'headroom_pct': 0.0, - 'clipped_pct': 0.0, - 'source_clipped_pct': 0.0, - 'near_white_pct': 0.0, - 'current_nearwhite_pct': 0.0 + "headroom_pct": 0.0, + "clipped_pct": 0.0, + "source_clipped_pct": 0.0, + "near_white_pct": 0.0, + "current_nearwhite_pct": 0.0, } - + # Headroom detection: Use pre-exposure buffer if available for "True Headroom" if pre_exposure_linear is not None: max_source = pre_exposure_linear.max(axis=2) @@ -105,7 +107,7 @@ def _analyze_highlight_state(rgb_linear: np.ndarray, srgb_u8: Optional[np.ndarra else: max_rgb = rgb_linear.max(axis=2) headroom_pct = float(np.count_nonzero(max_rgb > 1.0)) / total_pixels - + # 1. Source Clipping Statistics (True JPEG Clipping) # If srgb_u8 is provided, use it. Otherwise approximate from linear (less accurate if exposure shifted). if srgb_u8 is not None and srgb_u8.shape[:2] == rgb_linear.shape[:2]: @@ -119,8 +121,11 @@ def _analyze_highlight_state(rgb_linear: np.ndarray, srgb_u8: Optional[np.ndarra max_to_check = pre_exposure_linear.max(axis=2) else: max_to_check = rgb_linear.max(axis=2) - - source_clipped_pct = float(np.count_nonzero(max_to_check >= _LINEAR_THRESHOLD_254)) / total_pixels + + source_clipped_pct = ( + float(np.count_nonzero(max_to_check >= _LINEAR_THRESHOLD_254)) + / total_pixels + ) # 2. Current Near-White Statistics (for Pivot Nudging) # This drives the "micro-contrast feel" based on how bright the image IS NOW. @@ -128,20 +133,25 @@ def _analyze_highlight_state(rgb_linear: np.ndarray, srgb_u8: Optional[np.ndarra if pre_exposure_linear is not None: max_rgb = rgb_linear.max(axis=2) - current_nearwhite_pct = float(np.count_nonzero( - (max_rgb >= _LINEAR_THRESHOLD_250) & (max_rgb < _LINEAR_THRESHOLD_254) - )) / total_pixels + current_nearwhite_pct = ( + float( + np.count_nonzero( + (max_rgb >= _LINEAR_THRESHOLD_250) & (max_rgb < _LINEAR_THRESHOLD_254) + ) + ) + / total_pixels + ) # Legacy compat: near_white_pct usually referred to current state in previous logic? # Actually previous logic tried to use srgb_u8 if available for 'near_white_pct', which implies source. - # But for pivot nudging, we might want current? + # But for pivot nudging, we might want current? # The user said: "drive 'pivot nudging' off current_nearwhite_pct" and "drive 'JPEG fallback' off source_clipped_pct". # So we provide both. - + return { - 'headroom_pct': headroom_pct, - 'source_clipped_pct': source_clipped_pct, - 'current_nearwhite_pct': current_nearwhite_pct, + "headroom_pct": headroom_pct, + "source_clipped_pct": source_clipped_pct, + "current_nearwhite_pct": current_nearwhite_pct, } @@ -182,44 +192,51 @@ def _highlight_recover_linear( """ if amount < 0.001: return rgb_linear - + eps = 1e-7 - + # Use max-channel as brightness metric - handles saturated highlights better than luminance brightness = rgb_linear.max(axis=2) - + # Build smooth highlight mask: 0 below pivot, 1 in highlights # Use headroom_ceiling instead of 1.0 for the normalization range mask = _smoothstep01((brightness - pivot) / (headroom_ceiling - pivot + eps)) - + # Highlights recovery should DIM bright areas to reveal detail/contrast. # We use a gain-based approach that preserves the pivot and pull down highlights. # strength of 0.3 means max 30% darkening at pure white. recovery_strength = 0.3 target_brightness = brightness * (1.0 - amount * recovery_strength * mask) - + # Rescale RGB to preserve hue/chroma # Protect against div-by-zero or huge scale factors for near-black pixels scale = np.clip(target_brightness / (brightness + eps), 0.0, 2.0) scale = np.expand_dims(scale, axis=2) recovered = rgb_linear * scale - + # Optional chroma rolloff in extreme highlights to reduce "neon" colors if chroma_rolloff > 0.001: # Use target_brightness (post-compression) for the mask to maintain monotonicity # Normalize against headroom_ceiling for consistent behavior - extreme_mask = _smoothstep01((target_brightness - _CHROMA_ROLLOFF_START * headroom_ceiling) / (_CHROMA_ROLLOFF_WIDTH * headroom_ceiling)) + extreme_mask = _smoothstep01( + (target_brightness - _CHROMA_ROLLOFF_START * headroom_ceiling) + / (_CHROMA_ROLLOFF_WIDTH * headroom_ceiling) + ) extreme_mask = np.expand_dims(extreme_mask, axis=2) - + # Compute grayscale (luminance) of recovered image - gray = recovered[:, :, 0:1] * 0.2126 + recovered[:, :, 1:2] * 0.7152 + recovered[:, :, 2:3] * 0.0722 - + gray = ( + recovered[:, :, 0:1] * 0.2126 + + recovered[:, :, 1:2] * 0.7152 + + recovered[:, :, 2:3] * 0.0722 + ) + # Desaturate in extreme highlights - # Note: This preserves monotonicity because both recovered and gray are + # Note: This preserves monotonicity because both recovered and gray are # monotonic with respect to input brightness, and we blend between them. desat_amount = chroma_rolloff * amount * extreme_mask recovered = recovered * (1.0 - desat_amount) + gray * desat_amount - + return recovered @@ -230,33 +247,33 @@ def _highlight_boost_linear( pivot: float = 0.5, ) -> np.ndarray: """Apply highlight boost using brightness-based rescaling to preserve hue. - + Uses same hue-preserving approach as recovery for symmetry. - + Args: rgb_linear: Float32 RGB array (H, W, 3) in linear light amount: Boost strength 0.0-1.0 (mapped from slider 0 to 100) pivot: Brightness threshold below which minimal boost occurs - + Returns: Boosted float32 RGB array (linear) """ if amount < 0.001: return rgb_linear - + eps = 1e-7 - + brightness = rgb_linear.max(axis=2) - + # Build mask for highlights mask = _smoothstep01((brightness - pivot) / (1.0 - pivot + eps)) - + # Target brightness: lift with curve target_brightness = brightness * (1.0 + amount * 1.5 * mask) - + # Rescale RGB to preserve hue, cap scale at 1.5x to prevent blowout scale = np.clip(target_brightness / (brightness + eps), 0.0, 2.0) scale = np.minimum(scale, 1.5) # Direct cap on scale scale = np.expand_dims(scale, axis=2) - + return rgb_linear * scale diff --git a/faststack/imaging/metadata.py b/faststack/imaging/metadata.py index 03b261c..d1268e6 100644 --- a/faststack/imaging/metadata.py +++ b/faststack/imaging/metadata.py @@ -1,4 +1,3 @@ - import logging from pathlib import Path from typing import Dict, Any, Union @@ -6,6 +5,7 @@ log = logging.getLogger(__name__) + def clean_exif_value(value: Any) -> str: """ Cleans EXIF values for display. @@ -16,30 +16,31 @@ def clean_exif_value(value: Any) -> str: if isinstance(value, bytes): try: # Try to decode as UTF-8, stripping nulls - decoded = value.decode('utf-8').strip('\x00') + decoded = value.decode("utf-8").strip("\x00") # Check if the result is printable if decoded.isprintable(): return decoded return f"" except UnicodeDecodeError: return f"" - + if isinstance(value, str): # Strip null bytes and other common garbage - cleaned = value.strip('\x00').strip() + cleaned = value.strip("\x00").strip() # Remove other non-printable characters if necessary, but keep basic text # For now, just stripping nulls is the most important return cleaned - + if isinstance(value, (list, tuple)): return str([clean_exif_value(v) for v in value]) - + return str(value) + def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: """ Extracts EXIF data from an image file. - + Returns a dictionary with two keys: - 'summary': A dictionary of formatted common fields (Date, ISO, Aperture, etc.) - 'full': A dictionary of all decoded EXIF tags. @@ -54,7 +55,7 @@ def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: exif = img._getexif() finally: img.close() - + if not exif: return {"summary": {}, "full": {}} except Exception as e: # noqa: BLE001 - defensive catch for arbitrary EXIF parsing issues @@ -67,7 +68,7 @@ def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: decoded_exif[tag_name] = value summary = {} - + # Helper to safely get value def get_val(key): return decoded_exif.get(key) @@ -80,14 +81,16 @@ def get_val(key): # Camera Model make = get_val("Make") model = get_val("Model") - + # Clean make and model first - if make: make = clean_exif_value(make) - if model: model = clean_exif_value(model) - + if make: + make = clean_exif_value(make) + if model: + model = clean_exif_value(model) + if make and model: if make.lower() in model.lower(): - summary["Camera"] = model + summary["Camera"] = model else: summary["Camera"] = f"{make} {model}" elif model: @@ -111,7 +114,7 @@ def get_val(key): try: # FNumber is often a tuple (numerator, denominator) or a float if isinstance(f_number, tuple) and len(f_number) == 2: - val = f_number[0] / f_number[1] + val = f_number[0] / f_number[1] else: val = float(f_number) summary["Aperture"] = f"f/{val:.1f}" @@ -126,25 +129,25 @@ def get_val(key): val = exposure_time[0] / exposure_time[1] else: val = float(exposure_time) - + if val < 1: - summary["Shutter Speed"] = f"1/{int(1/val)}s" + summary["Shutter Speed"] = f"1/{int(1 / val)}s" else: summary["Shutter Speed"] = f"{val}s" except Exception: summary["Shutter Speed"] = clean_exif_value(exposure_time) - + # Focal Length focal_length = get_val("FocalLength") if focal_length: try: - if isinstance(focal_length, tuple) and len(focal_length) == 2: - val = focal_length[0] / focal_length[1] - else: - val = float(focal_length) - summary["Focal Length"] = f"{int(val)}mm" + if isinstance(focal_length, tuple) and len(focal_length) == 2: + val = focal_length[0] / focal_length[1] + else: + val = float(focal_length) + summary["Focal Length"] = f"{int(val)}mm" except Exception: - summary["Focal Length"] = clean_exif_value(focal_length) + summary["Focal Length"] = clean_exif_value(focal_length) # Flash flash = get_val("Flash") @@ -158,6 +161,7 @@ def get_val(key): gps_info = get_val("GPSInfo") if gps_info: try: + def convert_to_degrees(value): d = float(value[0]) m = float(value[1]) @@ -166,20 +170,20 @@ def convert_to_degrees(value): lat = None lon = None - - # GPSInfo keys are integers. + + # GPSInfo keys are integers. # 1: GPSLatitudeRef, 2: GPSLatitude # 3: GPSLongitudeRef, 4: GPSLongitude - + if 2 in gps_info and 4 in gps_info: lat = convert_to_degrees(gps_info[2]) lon = convert_to_degrees(gps_info[4]) - - if 1 in gps_info and gps_info[1] == 'S': + + if 1 in gps_info and gps_info[1] == "S": lat = -lat - if 3 in gps_info and gps_info[3] == 'W': + if 3 in gps_info and gps_info[3] == "W": lon = -lon - + summary["GPS"] = f"{lat:.5f}, {lon:.5f}" except Exception as e: log.warning(f"Failed to parse GPS info: {e}") @@ -193,7 +197,4 @@ def convert_to_degrees(value): # Apply cleaning to all values full_str = {str(k): clean_exif_value(v) for k, v in decoded_exif.items()} - return { - "summary": summary, - "full": full_str - } + return {"summary": summary, "full": full_str} diff --git a/faststack/imaging/optional_deps.py b/faststack/imaging/optional_deps.py index 6b1f9d1..e260196 100644 --- a/faststack/imaging/optional_deps.py +++ b/faststack/imaging/optional_deps.py @@ -2,6 +2,7 @@ try: import cv2 + HAS_OPENCV = True except ImportError: cv2 = None diff --git a/faststack/imaging/orientation.py b/faststack/imaging/orientation.py index a6ef532..ea2216d 100644 --- a/faststack/imaging/orientation.py +++ b/faststack/imaging/orientation.py @@ -8,13 +8,14 @@ log = logging.getLogger(__name__) + def get_exif_orientation(image_path: Path, exif: Optional[Image.Exif] = None) -> int: """Read the EXIF Orientation tag from an image file or provided EXIF object. - + Args: image_path: Path to the image file exif: Optional pre-read PIL Exif object - + Returns: Orientation value (1-8), defaults to 1 if missing or error. """ @@ -22,32 +23,33 @@ def get_exif_orientation(image_path: Path, exif: Optional[Image.Exif] = None) -> if exif is None: with Image.open(image_path) as img: exif = img.getexif() - + if not exif: return 1 - + # EXIF Orientation tag ID is 274 return exif.get(274, 1) except (OSError, IOError, AttributeError) as e: log.debug("Could not read EXIF orientation for %s: %s", image_path, e) return 1 + def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray: """Apply EXIF orientation transformation to a numpy image buffer. - + Args: buffer: Image as numpy array (H, W, 3) RGB uint8 or float32 orientation: Orientation value (1-8) - + Returns: Transformed numpy array. Guaranteed to be C-contiguous. """ if orientation <= 1: # Ensure C-contiguity even for identity orientation - if not buffer.flags['C_CONTIGUOUS']: + if not buffer.flags["C_CONTIGUOUS"]: return np.ascontiguousarray(buffer) return buffer - + # Apply transformation based on orientation if orientation == 2: # Mirrored horizontally @@ -72,16 +74,19 @@ def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray: result = np.rot90(buffer, k=1) else: # Unknown orientation - ensure C-contiguity - if not buffer.flags['C_CONTIGUOUS']: + if not buffer.flags["C_CONTIGUOUS"]: return np.ascontiguousarray(buffer) return buffer # Ensure result is C-contiguous after flip/rotate - if not result.flags['C_CONTIGUOUS']: + if not result.flags["C_CONTIGUOUS"]: result = np.ascontiguousarray(result) return result -def apply_exif_orientation(buffer: np.ndarray, image_path: Path, exif: Optional[Image.Exif] = None) -> np.ndarray: + +def apply_exif_orientation( + buffer: np.ndarray, image_path: Path, exif: Optional[Image.Exif] = None +) -> np.ndarray: """Helper that reads orientation and applies it to a numpy buffer.""" orientation = get_exif_orientation(image_path, exif) return apply_orientation_to_np(buffer, orientation) diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index 98b14a3..5383ee8 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -11,6 +11,7 @@ import numpy as np from PIL import Image as PILImage, ImageCms + try: from PySide6.QtCore import QTimer from PySide6.QtGui import QImage @@ -41,6 +42,7 @@ # Thread lock for all ICC caches _icc_cache_lock = threading.Lock() + def get_icc_transform( src_profile: ImageCms.ImageCmsProfile, monitor_profile: ImageCms.ImageCmsProfile, @@ -48,7 +50,7 @@ def get_icc_transform( monitor_profile_path: str, ) -> ImageCms.ImageCmsTransform: """Get or create a cached ICC transform. - + Building transforms is expensive, so we cache them by stable keys: - src_profile_key: SHA-256 digest of the embedded ICC bytes - monitor_profile_path: file path to the monitor ICC profile @@ -59,9 +61,14 @@ def get_icc_transform( _icc_transform_cache[key] = ImageCms.buildTransform( src_profile, monitor_profile, "RGB", "RGB" ) - log.debug("Built new ICC transform for profile pair (src=%s, monitor=%s)", src_profile_key[:16], monitor_profile_path) + log.debug( + "Built new ICC transform for profile pair (src=%s, monitor=%s)", + src_profile_key[:16], + monitor_profile_path, + ) return _icc_transform_cache[key] + def clear_icc_caches(): """Clear all ICC-related caches (profiles and transforms).""" global _monitor_profile_cache, _icc_transform_cache, _monitor_profile_warning_logged @@ -71,20 +78,21 @@ def clear_icc_caches(): _monitor_profile_warning_logged = False log.info("Cleared ICC profile and transform caches") + def get_monitor_profile() -> Optional[ImageCms.ImageCmsProfile]: """Dynamically load monitor ICC profile based on current config. - + Caches the profile by path to reduce overhead and log spam. """ global _monitor_profile_warning_logged - - monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() - + + monitor_icc_path = config.get("color", "monitor_icc_path", fallback="").strip() + with _icc_cache_lock: # Check cache first if monitor_icc_path in _monitor_profile_cache: return _monitor_profile_cache[monitor_icc_path] - + # Handle empty path case if not monitor_icc_path: if not _monitor_profile_warning_logged: @@ -92,21 +100,24 @@ def get_monitor_profile() -> Optional[ImageCms.ImageCmsProfile]: _monitor_profile_warning_logged = True _monitor_profile_cache[monitor_icc_path] = None return None - + # Load and cache the profile try: profile = ImageCms.ImageCmsProfile(monitor_icc_path) log.debug("Loaded monitor ICC profile: %s", monitor_icc_path) _monitor_profile_cache[monitor_icc_path] = profile except (OSError, ImageCms.PyCMSError) as e: - log.warning("Failed to load monitor ICC profile from %s: %s", monitor_icc_path, e) + log.warning( + "Failed to load monitor ICC profile from %s: %s", monitor_icc_path, e + ) _monitor_profile_cache[monitor_icc_path] = None - + return _monitor_profile_cache[monitor_icc_path] # apply_exif_orientation imported from orientation.py + def apply_saturation_compensation( arr: np.ndarray, width: int, @@ -120,7 +131,7 @@ def apply_saturation_compensation( arr: 1D uint8 array of length height * bytes_per_line width, height, bytes_per_line: dimensions of the image stored in arr factor: 0.0-1.0 range, where 1.0 = no change, <1.0 = less saturated - + Note: While the algorithm supports values >1.0 for increased saturation, the UI constrains the factor to [0.0, 1.0] for saturation reduction only. """ @@ -149,8 +160,16 @@ def apply_saturation_compensation( # Write back into the same memory rgb_region[:] = rgb.reshape(height, width * 3).astype(np.uint8) + class Prefetcher: - def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_radius: int, get_display_info: Callable, debug: bool = False): + def __init__( + self, + image_files: List[ImageFile], + cache_put: Callable, + prefetch_radius: int, + get_display_info: Callable, + debug: bool = False, + ): self.image_files = image_files self.cache_put = cache_put self.prefetch_radius = prefetch_radius @@ -159,21 +178,20 @@ def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_r # Use CPU count for I/O-bound JPEG decoding # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound optimal_workers = min((os.cpu_count() or 1) * 2, 4) # Cap at 4 - + self.executor = ThreadPoolExecutor( - max_workers=optimal_workers, - thread_name_prefix="Prefetcher" + max_workers=optimal_workers, thread_name_prefix="Prefetcher" ) self._futures_lock = threading.RLock() self.futures: Dict[int, Future] = {} self.generation = 0 self._scheduled: Dict[int, set] = {} # generation -> set of scheduled indices - + # Adaptive prefetch: start with smaller radius, expand after user navigates self._initial_radius = 2 # Small radius at startup to reduce cache thrash self._navigation_count = 0 # Track how many times user has navigated self._radius_expanded = False - + # Directional prefetching self._last_navigation_direction: int = 1 # 1 = forward, -1 = backward self._direction_bias: float = 0.7 # 70% of radius in travel direction @@ -183,9 +201,14 @@ def set_image_files(self, image_files: List[ImageFile]): self.image_files = image_files self.cancel_all() - def update_prefetch(self, current_index: int, is_navigation: bool = False, direction: Optional[int] = None): + def update_prefetch( + self, + current_index: int, + is_navigation: bool = False, + direction: Optional[int] = None, + ): """Updates the prefetching queue based on the current image index. - + Args: current_index: The index to prefetch around is_navigation: True if this is from user navigation (arrow keys, etc.) @@ -194,27 +217,37 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc # NOTE: Generation is NOT incremented here. It only changes when display size, # zoom state, or color mode changes - events that actually invalidate cached images. # Navigation just shifts which indices to prefetch. - + # OLD GENERATION CLEANUP MOVED TO INSIDE LOCK BELOW - + # Track navigation direction if direction is not None: self._last_navigation_direction = direction - + # Track navigation to expand radius after user starts moving if is_navigation: self._navigation_count += 1 if not self._radius_expanded and self._navigation_count >= 2: self._radius_expanded = True - log.info("Expanding prefetch radius from %d to %d after user navigation", self._initial_radius, self.prefetch_radius) - + log.info( + "Expanding prefetch radius from %d to %d after user navigation", + self._initial_radius, + self.prefetch_radius, + ) + # Use smaller radius initially to reduce cache thrash before display size is stable - effective_radius = self._initial_radius if not self._radius_expanded else self.prefetch_radius - + effective_radius = ( + self._initial_radius if not self._radius_expanded else self.prefetch_radius + ) + if self.debug: - log.info("Prefetch radius: initial=%d, configured=%d, effective=%d", - self._initial_radius, self.prefetch_radius, effective_radius) - + log.info( + "Prefetch radius: initial=%d, configured=%d, effective=%d", + self._initial_radius, + self.prefetch_radius, + effective_radius, + ) + # Calculate asymmetric range based on direction if self._last_navigation_direction > 0: # Moving forward behind = max(1, int(effective_radius * (1 - self._direction_bias))) @@ -222,12 +255,19 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc else: # Moving backward ahead = max(1, int(effective_radius * (1 - self._direction_bias))) behind = effective_radius - ahead + 1 - + start = max(0, current_index - behind) end = min(len(self.image_files), current_index + ahead + 1) - - log.debug("Prefetch range: [%d, %d) for index %d (direction=%d, behind=%d, ahead=%d)", - start, end, current_index, self._last_navigation_direction, behind, ahead) + + log.debug( + "Prefetch range: [%d, %d) for index %d (direction=%d, behind=%d, ahead=%d)", + start, + end, + current_index, + self._last_navigation_direction, + behind, + ahead, + ) # Cancel stale futures and remove from scheduled with self._futures_lock: @@ -236,7 +276,7 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc old_generations = [g for g in self._scheduled if g < self.generation] for g in old_generations: del self._scheduled[g] - + # Get scheduled set for current generation (inside lock to prevent race) scheduled = self._scheduled.setdefault(self.generation, set()) stale_keys = [] @@ -249,7 +289,7 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc del self.futures[key] # Submit new tasks - prioritize current image and direction of travel - + # Build priority order: current first, then in direction of travel priority_order = [current_index] if self._last_navigation_direction > 0: @@ -258,7 +298,7 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc else: priority_order.extend(range(current_index - 1, start - 1, -1)) priority_order.extend(range(current_index + 1, end)) - + for i in priority_order: if i < 0 or i >= len(self.image_files): continue @@ -266,9 +306,11 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc self.submit_task(i, self.generation) scheduled.add(i) - def submit_task(self, index: int, generation: int, priority: bool = False) -> Optional[Future]: + def submit_task( + self, index: int, generation: int, priority: bool = False + ) -> Optional[Future]: """Submits a decoding task for a given index. - + Args: index: Image index to decode generation: Generation number for cache invalidation @@ -276,7 +318,7 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op """ with self._futures_lock: if index in self.futures and not self.futures[index].done(): - return self.futures[index] # Already submitted + return self.futures[index] # Already submitted # For high-priority tasks (current image), cancel pending prefetch tasks # to free up worker threads and reduce blocking time @@ -285,12 +327,12 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op # Don't cancel tasks that are very close to the requested index (e.g. +/- 2) # This prevents thrashing when the user is navigating quickly safe_radius = 2 - + for task_index, future in list(self.futures.items()): # Skip the current task if task_index == index: continue - + # Skip tasks within safe radius if abs(task_index - index) <= safe_radius: continue @@ -299,26 +341,55 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op cancelled_count += 1 del self.futures[task_index] if cancelled_count > 0: - log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index) + log.debug( + "Cancelled %d pending prefetch tasks to prioritize index %d", + cancelled_count, + index, + ) image_file = self.image_files[index] display_width, display_height, display_generation = self.get_display_info() - future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) + future = self.executor.submit( + self._decode_and_cache, + image_file, + index, + generation, + display_width, + display_height, + display_generation, + ) self.futures[index] = future - log.debug("Submitted %s task for index %d", "priority" if priority else "prefetch", index) + log.debug( + "Submitted %s task for index %d", + "priority" if priority else "prefetch", + index, + ) return future - def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[Path, int]]: + def _decode_and_cache( + self, + image_file: ImageFile, + index: int, + generation: int, + display_width: int, + display_height: int, + display_generation: int, + ) -> Optional[tuple[Path, int]]: """The actual work done by the thread pool.""" import time - + t_start = time.perf_counter() exif_obj = None # Ensure variable is always initialized - + # Early check: if generation has already advanced since this task was submitted, skip it if generation != self.generation: - log.debug("Skipping stale task for index %d (submitted gen %d != current gen %d)", index, generation, self.generation) + log.debug( + "Skipping stale task for index %d (submitted gen %d != current gen %d)", + index, + generation, + self.generation, + ) return None try: @@ -328,137 +399,201 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, return None # Get current color management mode and optimization setting - color_mode = config.get('color', 'mode', fallback="none").lower() - optimize_for = config.get('core', 'optimize_for', fallback='speed').lower() - fast_dct = (optimize_for == 'speed') - use_resized = (optimize_for == 'speed') # Use decode_jpeg_resized for speed, decode_jpeg_rgb for quality - + color_mode = config.get("color", "mode", fallback="none").lower() + optimize_for = config.get("core", "optimize_for", fallback="speed").lower() + fast_dct = optimize_for == "speed" + use_resized = ( + optimize_for == "speed" + ) # Use decode_jpeg_resized for speed, decode_jpeg_rgb for quality + # Determine if we should resize - should_resize = (display_width > 0 and display_height > 0) + should_resize = display_width > 0 and display_height > 0 # Determine file type - is_jpeg = image_file.path.suffix.lower() in {'.jpg', '.jpeg', '.jpe'} + is_jpeg = image_file.path.suffix.lower() in {".jpg", ".jpeg", ".jpe"} # Option C: Full ICC pipeline - Use TurboJPEG for decode, Pillow only for ICC conversion if color_mode == "icc": monitor_profile = get_monitor_profile() - monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() - + monitor_icc_path = config.get( + "color", "monitor_icc_path", fallback="" + ).strip() + if monitor_profile is not None: # FAST: Use TurboJPEG for decode + resize (ONLY for JPEGs) buffer = None t_before_read = time.perf_counter() - + if is_jpeg: - try: + try: with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + with mmap.mmap( + f.fileno(), 0, access=mmap.ACCESS_READ + ) as mmapped: # Pass mmap directly - no copy! Decoders accept bytes-like objects if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + buffer = decode_jpeg_resized( + mmapped, + display_width, + display_height, + fast_dct=fast_dct, + ) else: # Quality mode or Full Res: decode full image then resize with high quality - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + buffer = decode_jpeg_rgb( + mmapped, fast_dct=fast_dct + ) if buffer is not None and should_resize: img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) buffer = np.array(img) - except Exception: - log.debug("TurboJPEG failed on JPEG %s, falling back", image_file.path) - buffer = None - + except Exception: + log.debug( + "TurboJPEG failed on JPEG %s, falling back", + image_file.path, + ) + buffer = None + # If not JPEG or TurboJPEG failed, try generic Pillow load if buffer is None: try: - # We can't use mmap for Generic Pillow open widely (some formats need seek/tell on file) - # So we open nominally. - with PILImage.open(image_file.path) as img: - img = img.convert("RGB") - if should_resize: - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) + # We can't use mmap for Generic Pillow open widely (some formats need seek/tell on file) + # So we open nominally. + with PILImage.open(image_file.path) as img: + img = img.convert("RGB") + if should_resize: + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) + buffer = np.array(img) except Exception as e: - log.warning("Failed to decode image %s: %s", image_file.path, e) - return None + log.warning( + "Failed to decode image %s: %s", image_file.path, e + ) + return None t_after_read = time.perf_counter() if buffer is None: return None t_after_decode = time.perf_counter() - + # Convert numpy array to PIL Image for ICC conversion img = PILImage.fromarray(buffer) t_after_array_to_pil = time.perf_counter() - + # Extract ICC profile AND EXIF from original file (need to read header only) t_before_profile_read = time.perf_counter() exif_obj = None with PILImage.open(image_file.path) as orig: icc_bytes = orig.info.get("icc_profile") - exif_obj = orig.getexif() # Capture EXIF while open + exif_obj = orig.getexif() # Capture EXIF while open t_after_profile_read = time.perf_counter() - + src_profile = None src_profile_key = None if icc_bytes: try: - src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) + src_profile = ImageCms.ImageCmsProfile( + io.BytesIO(icc_bytes) + ) # Compute stable key: SHA-256 digest of ICC bytes src_profile_key = hashlib.sha256(icc_bytes).hexdigest() - log.debug("Using embedded ICC profile from %s", image_file.path) + log.debug( + "Using embedded ICC profile from %s", image_file.path + ) except (OSError, ImageCms.PyCMSError, ValueError) as e: - log.warning("Failed to parse ICC profile from %s: %s", image_file.path, e) - + log.warning( + "Failed to parse ICC profile from %s: %s", + image_file.path, + e, + ) + if src_profile is None: src_profile = SRGB_PROFILE # Use a constant key for sRGB since it's always the same src_profile_key = "srgb_builtin" - log.debug("No embedded profile, assuming sRGB for %s", image_file.path) - + log.debug( + "No embedded profile, assuming sRGB for %s", image_file.path + ) + # Convert from source profile to monitor profile using cached transform try: log.debug("Converting image from source to monitor profile") t_before_icc = time.perf_counter() - transform = get_icc_transform(src_profile, monitor_profile, src_profile_key, monitor_icc_path) + transform = get_icc_transform( + src_profile, + monitor_profile, + src_profile_key, + monitor_icc_path, + ) # Alan 11-20-25 - Add inPlace=True to speed up copy, shouldn't have many negative effects ImageCms.applyTransform(img, transform, inPlace=True) t_after_icc = time.perf_counter() - + rgb = np.array(img, dtype=np.uint8) - + # Note: We do NOT apply EXIF orientation here anymore. # It is handled in the Unified EXIF Orientation Application block below. # This avoids "double rotation" or potential "apply and discard" bugs. - + # Memory Optimization: Avoid explicit copy buffer = np.ascontiguousarray(rgb) bytes_per_line = buffer.strides[0] mv = memoryview(buffer).cast("B") t_after_copy = time.perf_counter() - + if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("ICC decode timing for index %d (%s): read=%.3fs, decode=%.3fs, array_to_pil=%.3fs, profile_read=%.3fs, icc=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_array_to_pil - t_after_decode, t_after_profile_read - t_before_profile_read, - t_after_icc - t_before_icc, t_after_copy - t_after_icc, - t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) + log.info( + "ICC decode timing for index %d (%s): read=%.3fs, decode=%.3fs, array_to_pil=%.3fs, profile_read=%.3fs, icc=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, + decoder, + t_after_read - t_before_read, + t_after_decode - t_after_read, + t_after_array_to_pil - t_after_decode, + t_after_profile_read - t_before_profile_read, + t_after_icc - t_before_icc, + t_after_copy - t_after_icc, + t_after_copy - t_start, + buffer.shape[1], + buffer.shape[0], + ) except (OSError, ImageCms.PyCMSError, ValueError) as e: # ICC conversion failed, fall back to standard decode - log.warning("ICC profile conversion failed for %s: %s, falling back to standard decode", image_file.path, e) + log.warning( + "ICC profile conversion failed for %s: %s, falling back to standard decode", + image_file.path, + e, + ) t_before_fallback_read = time.perf_counter() if is_jpeg: # JPEG-specific fast path with mmap + TurboJPEG with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + with mmap.mmap( + f.fileno(), 0, access=mmap.ACCESS_READ + ) as mmapped: if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + buffer = decode_jpeg_resized( + mmapped, + display_width, + display_height, + fast_dct=fast_dct, + ) else: - buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) + buffer = decode_jpeg_rgb( + mmapped, fast_dct=fast_dct + ) if buffer is not None and should_resize: img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) buffer = np.array(img) else: # Generic Pillow fallback for non-JPEGs @@ -466,21 +601,28 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, with PILImage.open(image_file.path) as img: img = img.convert("RGB") if should_resize: - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) buffer = np.array(img) except Exception as e: - log.warning("Pillow fallback failed for %s: %s", image_file.path, e) + log.warning( + "Pillow fallback failed for %s: %s", + image_file.path, + e, + ) return None t_after_fallback_read = time.perf_counter() if buffer is None: return None t_after_fallback_decode = time.perf_counter() - + # EXIF orientation correction pass - + # Memory Optimization: Avoid explicit copy buffer = np.ascontiguousarray(buffer) bytes_per_line = buffer.strides[0] @@ -488,30 +630,48 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, # Align with non-fallback paths for timing/logging t_after_copy = time.perf_counter() - + if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_fallback_read - t_before_fallback_read, - t_after_fallback_decode - t_after_fallback_read, - t_after_copy - t_after_fallback_decode, - t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) + log.info( + "ICC fallback decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, + decoder, + t_after_fallback_read - t_before_fallback_read, + t_after_fallback_decode - t_after_fallback_read, + t_after_copy - t_after_fallback_decode, + t_after_copy - t_start, + buffer.shape[1], + buffer.shape[0], + ) else: # Fall back to standard decode if ICC profile not available - log.warning("ICC mode selected but no monitor profile available, using standard decode") + log.warning( + "ICC mode selected but no monitor profile available, using standard decode" + ) t_before_read = time.perf_counter() if is_jpeg: # JPEG-specific fast path with mmap + TurboJPEG with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + with mmap.mmap( + f.fileno(), 0, access=mmap.ACCESS_READ + ) as mmapped: if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + buffer = decode_jpeg_resized( + mmapped, + display_width, + display_height, + fast_dct=fast_dct, + ) else: buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) if buffer is not None and should_resize: img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) buffer = np.array(img) else: # Generic Pillow fallback for non-JPEGs @@ -519,19 +679,24 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, with PILImage.open(image_file.path) as img: img = img.convert("RGB") if should_resize: - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) buffer = np.array(img) except Exception as e: - log.warning("Pillow fallback failed for %s: %s", image_file.path, e) + log.warning( + "Pillow fallback failed for %s: %s", image_file.path, e + ) return None t_after_read = time.perf_counter() if buffer is None: return None t_after_decode = time.perf_counter() - + # EXIF orientation application - + # Memory Optimization: Avoid explicit copy buffer = np.ascontiguousarray(buffer) bytes_per_line = buffer.strides[0] @@ -539,49 +704,69 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, # Align with non-fallback paths for timing/logging t_after_copy = time.perf_counter() - + if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, - t_after_copy - t_start, buffer.shape[1], buffer.shape[0]) - + log.info( + "Standard decode timing (no ICC profile) for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, + decoder, + t_after_read - t_before_read, + t_after_decode - t_after_read, + t_after_copy - t_after_decode, + t_after_copy - t_start, + buffer.shape[1], + buffer.shape[0], + ) + else: # Standard decode path (Option A or no color management) t_before_read = time.perf_counter() - + buffer = None if is_jpeg: try: with open(image_file.path, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + with mmap.mmap( + f.fileno(), 0, access=mmap.ACCESS_READ + ) as mmapped: if use_resized and should_resize: - buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) + buffer = decode_jpeg_resized( + mmapped, + display_width, + display_height, + fast_dct=fast_dct, + ) else: buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) if buffer is not None and should_resize: img = PILImage.fromarray(buffer) - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) buffer = np.array(img) except Exception: buffer = None - + if buffer is None: try: - with PILImage.open(image_file.path) as img: - img = img.convert("RGB") - if should_resize: - img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) - buffer = np.array(img) + with PILImage.open(image_file.path) as img: + img = img.convert("RGB") + if should_resize: + img.thumbnail( + (display_width, display_height), + PILImage.Resampling.LANCZOS, + ) + buffer = np.array(img) except Exception as e: - log.warning("Failed to decode image %s: %s", image_file.path, e) - return None + log.warning("Failed to decode image %s: %s", image_file.path, e) + return None t_after_read = time.perf_counter() if buffer is None: return None t_after_decode = time.perf_counter() - + # EXIF orientation correction moved to post-decode block # Memory Optimization: Avoid explicit copy @@ -598,13 +783,21 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, # Optimization: Use pre-read EXIF object if available (ICC path) # For non-ICC path, we might still need to open it. if exif_obj is not None: - buffer = apply_exif_orientation(buffer, image_file.path, exif=exif_obj) + buffer = apply_exif_orientation( + buffer, image_file.path, exif=exif_obj + ) else: # Fallback to opening (Non-ICC path or where we didn't capture it) with PILImage.open(image_file.path) as img: - buffer = apply_exif_orientation(buffer, image_file.path, exif=img.getexif()) + buffer = apply_exif_orientation( + buffer, image_file.path, exif=img.getexif() + ) except Exception as e: - log.warning("Failed to apply EXIF orientation for %s: %s", image_file.path, e) + log.warning( + "Failed to apply EXIF orientation for %s: %s", + image_file.path, + e, + ) # Always re-establish these no matter what happened h, w = buffer.shape[:2] @@ -613,67 +806,111 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, mv = memoryview(buffer).cast("B") if self.debug and (w != pre_w or h != pre_h): - log.info("Applied EXIF orientation for index %d: %dx%d -> %dx%d", index, pre_w, pre_h, w, h) + log.info( + "Applied EXIF orientation for index %d: %dx%d -> %dx%d", + index, + pre_w, + pre_h, + w, + h, + ) # Apply saturation compensation if enabled if color_mode == "saturation": try: - factor = float(config.get('color', 'saturation_factor', fallback="1.0")) - + factor = float( + config.get("color", "saturation_factor", fallback="1.0") + ) + # Ensure buffer is contiguous and create a 1D view for saturation compensation # Note: buffer is already made contiguous (np.ascontiguousarray) in the decode blocks above or orientation block arr = buffer.ravel() - + # Verify shape expectations if self.debug: - assert buffer.flags['C_CONTIGUOUS'], "Buffer must be C-contiguous for in-place modification" - assert arr.size == h * bytes_per_line, f"Buffer size mismatch: {arr.size} != {h} * {bytes_per_line}" - assert arr.dtype == np.uint8, f"Buffer dtype must be uint8, got {arr.dtype}" - + assert buffer.flags["C_CONTIGUOUS"], ( + "Buffer must be C-contiguous for in-place modification" + ) + assert arr.size == h * bytes_per_line, ( + f"Buffer size mismatch: {arr.size} != {h} * {bytes_per_line}" + ) + assert arr.dtype == np.uint8, ( + f"Buffer dtype must be uint8, got {arr.dtype}" + ) + apply_saturation_compensation(arr, w, h, bytes_per_line, factor) t_after_saturation = time.perf_counter() - + if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Saturation decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, saturation=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, t_after_saturation - t_after_copy, - t_after_saturation - t_start, w, h) + log.info( + "Saturation decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, saturation=%.3fs, total=%.3fs, size=%dx%d", + index, + decoder, + t_after_read - t_before_read, + t_after_decode - t_after_read, + t_after_copy - t_after_decode, + t_after_saturation - t_after_copy, + t_after_saturation - t_start, + w, + h, + ) except (ValueError, AssertionError) as e: log.warning("Failed to apply saturation compensation: %s", e) else: # No color management - log standard timing if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Standard decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, t_after_copy - t_start, w, h) - + log.info( + "Standard decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, + decoder, + t_after_read - t_before_read, + t_after_decode - t_after_read, + t_after_copy - t_after_decode, + t_after_copy - t_start, + w, + h, + ) + # Re-check generation before caching (in case it changed during decode) if self.generation != generation: - log.debug("Generation changed for index %d before caching (current gen %d != submitted gen %d). Skipping cache_put.", index, self.generation, generation) + log.debug( + "Generation changed for index %d before caching (current gen %d != submitted gen %d). Skipping cache_put.", + index, + self.generation, + generation, + ) return None - + decoded_image = DecodedImage( buffer=mv, width=w, height=h, bytes_per_line=bytes_per_line, - format=QImage.Format.Format_RGB888 if QImage else None + format=QImage.Format.Format_RGB888 if QImage else None, ) cache_key = build_cache_key(image_file.path, display_generation) self.cache_put(cache_key, decoded_image) - log.debug("Successfully decoded and cached image at index %d for display gen %d", index, display_generation) + log.debug( + "Successfully decoded and cached image at index %d for display gen %d", + index, + display_generation, + ) return image_file.path, display_generation - + except (OSError, IOError, ValueError, MemoryError) as e: - log.warning("Error decoding image %s at index %d: %s", image_file.path, index, e) - + log.warning( + "Error decoding image %s at index %d: %s", image_file.path, index, e + ) + return None - def _is_in_prefetch_range(self, index: int, current_index: int, radius: Optional[int] = None) -> bool: + def _is_in_prefetch_range( + self, index: int, current_index: int, radius: Optional[int] = None + ) -> bool: """Checks if an index is within the current prefetch window. - + Args: index: The index to check current_index: The center of the prefetch window diff --git a/faststack/io/deletion.py b/faststack/io/deletion.py new file mode 100644 index 0000000..972eb4b --- /dev/null +++ b/faststack/io/deletion.py @@ -0,0 +1,153 @@ +"""Deletion logic for FastStack.""" + +import logging +from pathlib import Path +from PySide6.QtWidgets import QMessageBox + +log = logging.getLogger(__name__) + +def ensure_recycle_bin_dir(recycle_bin_dir: Path) -> bool: + """Try to create the recycle bin directory. + + Returns: + True if recycle bin exists or was created successfully. + False if creation failed (e.g., permission denied). + """ + try: + recycle_bin_dir.mkdir(parents=True, exist_ok=True) + return True + except (PermissionError, OSError) as e: + log.error("Failed to create recycle bin directory: %s", e) + return False + +def confirm_permanent_delete(image_file, reason: str = "") -> bool: + """Show a confirmation dialog for permanent deletion of a single image. + + Args: + image_file: The ImageFile to delete permanently. + reason: Reason for permanent deletion (e.g., "Recycle bin unavailable"). + + Returns: + True if user confirms deletion, False if cancelled. + """ + jpg_path = image_file.path + raw_path = image_file.raw_pair + + # Build list of files that will be deleted + files_to_delete = [str(jpg_path.name)] + if raw_path and raw_path.exists(): + files_to_delete.append(str(raw_path.name)) + + file_list = "\n".join(f" • {f}" for f in files_to_delete) + + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle("Permanent Deletion") + + if reason: + msg_box.setText(f"{reason}\n\nDeletion is permanent and cannot be undone.") + else: + msg_box.setText("Deletion is permanent and cannot be undone.") + + msg_box.setInformativeText( + f"The following files will be permanently deleted:\n{file_list}" + ) + + delete_btn = msg_box.addButton( + "Delete Permanently", QMessageBox.DestructiveRole + ) + cancel_btn = msg_box.addButton("Cancel", QMessageBox.RejectRole) + msg_box.setDefaultButton(cancel_btn) + + msg_box.exec() + + return msg_box.clickedButton() == delete_btn + +def confirm_batch_permanent_delete(images: list, reason: str = "") -> bool: + """Show a confirmation dialog for permanent deletion of multiple images. + + Args: + images: List of ImageFile objects to delete permanently. + reason: Reason for permanent deletion. + + Returns: + True if user confirms deletion, False if cancelled. + """ + # Count total files (JPG + RAW pairs) + total_files = 0 + file_names = [] + for img in images: + file_names.append(img.path.name) + total_files += 1 + if img.raw_pair and img.raw_pair.exists(): + total_files += 1 + + msg_box = QMessageBox() + msg_box.setIcon(QMessageBox.Warning) + msg_box.setWindowTitle("Permanent Deletion") + + if reason: + msg_box.setText( + f"{reason}\n\nThis will permanently delete {len(images)} image(s) ({total_files} files)." + ) + else: + msg_box.setText( + f"This will permanently delete {len(images)} image(s) ({total_files} files)." + ) + + # Show first few file names, collapse if too many + if len(file_names) <= 5: + file_list = "\n".join(f" • {f}" for f in file_names) + msg_box.setInformativeText( + f"Files to delete:\n{file_list}\n\nThis action cannot be undone." + ) + else: + first_few = "\n".join(f" • {f}" for f in file_names[:3]) + msg_box.setInformativeText( + f"Files to delete:\n{first_few}\n ... and {len(file_names) - 3} more\n\nThis action cannot be undone." + ) + + delete_btn = msg_box.addButton( + f"Delete {len(images)} Images", QMessageBox.DestructiveRole + ) + cancel_btn = msg_box.addButton("Cancel", QMessageBox.RejectRole) + msg_box.setDefaultButton(cancel_btn) + + msg_box.exec() + + return msg_box.clickedButton() == delete_btn + +def permanently_delete_image_files(image_file) -> bool: + """Permanently delete an image and its RAW pair from disk. + + This does NOT add to undo history since deletion is permanent. + + Args: + image_file: The ImageFile to delete. + + Returns: + True if at least one file was deleted, False otherwise. + """ + deleted_any = False + jpg_path = image_file.path + raw_path = image_file.raw_pair + + # Delete JPG + if jpg_path and jpg_path.exists(): + try: + jpg_path.unlink() + log.info("Permanently deleted: %s", jpg_path.name) + deleted_any = True + except OSError as e: + log.error("Failed to permanently delete %s: %s", jpg_path.name, e) + + # Delete RAW if exists + if raw_path and raw_path.exists(): + try: + raw_path.unlink() + log.info("Permanently deleted: %s", raw_path.name) + deleted_any = True + except OSError as e: + log.error("Failed to permanently delete %s: %s", raw_path.name, e) + + return deleted_any diff --git a/faststack/io/executable_validator.py b/faststack/io/executable_validator.py index b8f32a5..808f6c1 100644 --- a/faststack/io/executable_validator.py +++ b/faststack/io/executable_validator.py @@ -3,7 +3,7 @@ import logging import os from pathlib import Path -from typing import Optional, List +from typing import Optional log = logging.getLogger(__name__) @@ -21,18 +21,16 @@ def validate_executable_path( - exe_path: str, - app_type: Optional[str] = None, - allow_custom_paths: bool = True + exe_path: str, app_type: Optional[str] = None, allow_custom_paths: bool = True ) -> tuple[bool, Optional[str]]: """ Validates an executable path before execution. - + Args: exe_path: Path to the executable to validate app_type: Type of application (e.g., 'photoshop', 'helicon') for additional checks allow_custom_paths: Whether to allow executables outside known safe paths - + Returns: Tuple of (is_valid, error_message) If valid, error_message is None @@ -40,24 +38,24 @@ def validate_executable_path( """ if not exe_path: return False, "Executable path is empty" - + try: path = Path(exe_path).resolve() except (ValueError, OSError) as e: log.exception(f"Invalid path format: {exe_path}") return False, f"Invalid path format: {e}" - + # Check if file exists if not path.exists(): return False, f"Executable not found: {exe_path}" - + if not path.is_file(): return False, f"Path is not a file: {exe_path}" - + # Check if it's actually an executable if not _is_executable(path): return False, f"File is not executable: {exe_path}" - + # Check if the executable name matches expected names for the app type if app_type and app_type in KNOWN_SAFE_EXECUTABLES: expected_names = KNOWN_SAFE_EXECUTABLES[app_type] @@ -68,13 +66,12 @@ def validate_executable_path( ) if not allow_custom_paths: return False, f"Executable name mismatch: {path.name}" - + # Check if in known safe directory in_safe_path = any( - _is_subpath(path, Path(safe_path)) - for safe_path in KNOWN_SAFE_PATHS + _is_subpath(path, Path(safe_path)) for safe_path in KNOWN_SAFE_PATHS ) - + if not in_safe_path: if not allow_custom_paths: return False, f"Executable not in allowed directory: {exe_path}" @@ -83,7 +80,7 @@ def validate_executable_path( f"Executable '{exe_path}' is not in a known safe directory. " f"Proceeding with caution." ) - + # Check for suspicious paths (potential directory traversal, etc.) try: normalized = os.path.normpath(exe_path) @@ -94,18 +91,18 @@ def validate_executable_path( except (ValueError, OSError) as e: log.exception("Error normalizing path") return False, f"Path validation error: {e}" - + return True, None def _is_executable(path: Path) -> bool: """Check if a file is executable (has .exe extension on Windows).""" # Always accept .exe extension (mocked tests might run on Linux) - if path.suffix.lower() == '.exe': + if path.suffix.lower() == ".exe": return True - - if os.name == 'nt': # Windows - return path.suffix.lower() == '.exe' + + if os.name == "nt": # Windows + return path.suffix.lower() == ".exe" else: # Unix-like return os.access(path, os.X_OK) diff --git a/faststack/io/helicon.py b/faststack/io/helicon.py index 5969048..49ccf3a 100644 --- a/faststack/io/helicon.py +++ b/faststack/io/helicon.py @@ -13,6 +13,7 @@ log = logging.getLogger(__name__) + def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: """Launches Helicon Focus with the provided list of RAW files. @@ -32,11 +33,9 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: # Validate executable path securely is_valid, error_msg = validate_executable_path( - helicon_exe, - app_type="helicon", - allow_custom_paths=True + helicon_exe, app_type="helicon", allow_custom_paths=True ) - + if not is_valid: log.error(f"Helicon Focus executable validation failed: {error_msg}") return False, None @@ -46,7 +45,9 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: return False, None try: - with tempfile.NamedTemporaryFile("w", delete=False, suffix=".txt", encoding='utf-8') as tmp: + with tempfile.NamedTemporaryFile( + "w", delete=False, suffix=".txt", encoding="utf-8" + ) as tmp: for f in raw_files: # Ensure file path is resolved and exists if not f.exists(): @@ -60,14 +61,14 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: # Build command list safely args = [helicon_exe, "-i", str(tmp_path.resolve())] - + # Parse additional args safely using shlex (handles quotes and escapes properly) extra_args = config.get("helicon", "args") if extra_args: try: # Use shlex to properly parse arguments with quotes/escapes # On Windows, use posix=False to handle Windows-style paths - parsed_args = shlex.split(extra_args, posix=(os.name != 'nt')) + parsed_args = shlex.split(extra_args, posix=(os.name != "nt")) args.extend(parsed_args) except ValueError as e: log.exception(f"Invalid helicon args format: {e}") @@ -75,7 +76,7 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: log.info(f"Launching Helicon Focus with {len(raw_files)} files") log.info(f"Command: {' '.join(args)}") - + # SECURITY: Explicitly disable shell execution subprocess.Popen( args, @@ -83,7 +84,7 @@ def launch_helicon_focus(raw_files: List[Path]) -> Tuple[bool, Optional[Path]]: stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - close_fds=True # Close unused file descriptors + close_fds=True, # Close unused file descriptors ) return True, tmp_path except (OSError, subprocess.SubprocessError) as e: diff --git a/faststack/io/indexer.py b/faststack/io/indexer.py index c9a14a4..af7dcd8 100644 --- a/faststack/io/indexer.py +++ b/faststack/io/indexer.py @@ -10,43 +10,45 @@ log = logging.getLogger(__name__) -RAW_EXTENSIONS = { - ".ORF", ".RW2", ".CR2", ".CR3", ".ARW", ".NEF", ".RAF", ".DNG", - ".orf", ".rw2", ".cr2", ".cr3", ".arw", ".nef", ".raf", ".dng", -} +RAW_EXTENSIONS = {".orf", ".rw2", ".cr2", ".cr3", ".arw", ".nef", ".raf", ".dng"} + +JPG_EXTENSIONS = {".jpg", ".jpeg", ".jpe"} + +_DEVELOPED_SUFFIX = "-developed" -JPG_EXTENSIONS = { ".JPG", ".JPEG", ".jpg", ".jpeg" } def find_images(directory: Path) -> List[ImageFile]: """Finds all JPGs in a directory and pairs them with RAW files.""" t_start = time.perf_counter() log.info("Scanning directory for images: %s", directory) - + # Categorize files all_jpgs: List[Tuple[Path, os.stat_result]] = [] - raws: Dict[str, List[Tuple[Path, os.stat_result]]] = {} - + raws: Dict[str, List[Tuple[Path, os.stat_result]]] = {} # keyed by stem.casefold() + try: for entry in os.scandir(directory): if entry.is_file(): p = Path(entry.path) - ext = p.suffix + ext = p.suffix.lower() if ext in JPG_EXTENSIONS: all_jpgs.append((p, entry.stat())) elif ext in RAW_EXTENSIONS: - stem = p.stem + stem = p.stem.casefold() if stem not in raws: raws[stem] = [] raws[stem].append((p, entry.stat())) - except OSError as e: + except OSError: log.exception("Error scanning directory %s", directory) return [] # Separate developed JPGs, build base map, and process normal JPGs # base_map: filename.casefold() -> (mtime, name) base_map: Dict[str, Tuple[float, str]] = {} - developed_candidates: List[Tuple[Path, os.stat_result, str]] = [] # path, stat, base_stem - + developed_candidates: List[ + Tuple[Path, os.stat_result, str] + ] = [] # path, stat, base_stem + image_entries: List[Tuple[Tuple[float, str, int, str], ImageFile]] = [] used_raws = set() @@ -57,70 +59,111 @@ def find_images(directory: Path) -> List[ImageFile]: else: # Register in base_map for developed images to find their parents base_map[p.name.casefold()] = (stat.st_mtime, p.name) - + # Process as normal JPG - raw_pair = _find_raw_pair(p, stat, raws.get(p.stem, [])) + raw_pair = _find_raw_pair(p, stat, raws.get(p.stem.casefold(), [])) if raw_pair: used_raws.add(raw_pair) - + img = ImageFile(path=p, raw_pair=raw_pair, timestamp=stat.st_mtime) - image_entries.append(((stat.st_mtime, p.name.casefold(), 0, p.name.casefold()), img)) + image_entries.append( + ((stat.st_mtime, p.name.casefold(), 0, p.name.casefold()), img) + ) # 2. Process Developed JPGs for p, stat, base_stem in developed_candidates: # Try to find base image in priority order: .jpg, .jpeg, .jpe effective_ts = stat.st_mtime effective_name = p.name.casefold() - - for ext in [".jpg", ".jpeg", ".jpe"]: + + for ext in sorted(JPG_EXTENSIONS): candidate = (base_stem + ext).casefold() if candidate in base_map: base_ts, base_name = base_map[candidate] effective_ts = base_ts effective_name = base_name.casefold() break - - img = ImageFile(path=p, raw_pair=None, timestamp=stat.st_mtime) - image_entries.append(((effective_ts, effective_name, 1, p.name.casefold()), img)) + + # Store the effective timestamp so downstream sorts/grouping keep it adjacent to the base image. + img = ImageFile(path=p, raw_pair=None, timestamp=effective_ts) + image_entries.append( + ((effective_ts, effective_name, 1, p.name.casefold()), img) + ) # 3. Handle orphaned RAWs for stem, raw_list in raws.items(): for raw_path, raw_stat in raw_list: if raw_path not in used_raws: - img = ImageFile(path=raw_path, raw_pair=raw_path, timestamp=raw_stat.st_mtime) - image_entries.append(((raw_stat.st_mtime, raw_path.name.casefold(), 0, raw_path.name.casefold()), img)) + img = ImageFile( + path=raw_path, raw_pair=raw_path, timestamp=raw_stat.st_mtime + ) + image_entries.append( + ( + ( + raw_stat.st_mtime, + raw_path.name.casefold(), + 0, + raw_path.name.casefold(), + ), + img, + ) + ) # Final Sort image_entries.sort(key=lambda x: x[0]) image_files = [x[1] for x in image_entries] elapsed = time.perf_counter() - t_start - paired_count = sum(1 for im in image_files if im.raw_pair and im.path.suffix.lower() in JPG_EXTENSIONS) - raw_only_count = sum(1 for im in image_files if im.path.suffix.lower() not in JPG_EXTENSIONS) - + paired_count = sum( + 1 + for im in image_files + if im.raw_pair and im.path.suffix.lower() in JPG_EXTENSIONS + ) + raw_only_count = sum( + 1 for im in image_files if im.path.suffix.lower() not in JPG_EXTENSIONS + ) + if log.isEnabledFor(logging.DEBUG): - log.info("Found %d total, %d paired, %d raw-only in %.3fs", - len(image_files), paired_count, raw_only_count, elapsed) + log.info( + "Found %d total, %d paired, %d raw-only in %.3fs", + len(image_files), + paired_count, + raw_only_count, + elapsed, + ) else: - log.info("Found %d images (%d paired, %d raw-only).", len(image_files), paired_count, raw_only_count) + log.info( + "Found %d images (%d paired, %d raw-only).", + len(image_files), + paired_count, + raw_only_count, + ) return image_files + def _parse_developed(path: Path) -> Tuple[bool, str]: """ - Detects if a file is a developed image. + Detect if a file is a developed image. Returns (is_developed, base_stem). - Suffix match for '-developed' is case-insensitive. + + Matches a trailing '-developed' on the filename stem, case-insensitive. + Example: 'IMG_0001-developed.jpg' -> ('IMG_0001') """ stem = path.stem - if stem.lower().endswith("-developed"): - base_stem = stem[:-10] # Remove "-developed" + stem_cf = stem.casefold() + suf_cf = _DEVELOPED_SUFFIX.casefold() + + if stem_cf.endswith(suf_cf): + base_stem = stem[: -len(_DEVELOPED_SUFFIX)] return True, base_stem + return False, "" + def _find_raw_pair( jpg_path: Path, jpg_stat: os.stat_result, - potential_raws: List[Tuple[Path, os.stat_result]] + potential_raws: List[Tuple[Path, os.stat_result]], ) -> Path | None: """Finds the best RAW pair for a JPG from a list of candidates.""" if not potential_raws: @@ -128,7 +171,7 @@ def _find_raw_pair( # Find the RAW file with the closest modification time within a 2-second window best_match: Path | None = None - min_dt = 2.0 # seconds + min_dt = 2.0 # seconds for raw_path, raw_stat in potential_raws: dt = abs(jpg_stat.st_mtime - raw_stat.st_mtime) diff --git a/faststack/io/sidecar.py b/faststack/io/sidecar.py index 653ed5e..8137852 100644 --- a/faststack/io/sidecar.py +++ b/faststack/io/sidecar.py @@ -4,12 +4,12 @@ import logging import time from pathlib import Path -from typing import Optional from faststack.models import Sidecar, EntryMetadata log = logging.getLogger(__name__) + def _entrymetadata_from_json(meta: dict) -> EntryMetadata: """ Helper to create EntryMetadata from JSON dict, handling legacy fields @@ -17,21 +17,23 @@ def _entrymetadata_from_json(meta: dict) -> EntryMetadata: """ try: # Handle legacy keys - # Legacy 'flag' and 'reject' do not map to current EntryMetadata fields, + # Legacy 'flag' and 'reject' do not map to current EntryMetadata fields, # so they will be filtered out by valid_keys check below. - + # stack_id IS in the current model, so we keep it (don't delete it). # Filter out unknown keys import dataclasses + valid_keys = {f.name for f in dataclasses.fields(EntryMetadata)} filtered_meta = {k: v for k, v in meta.items() if k in valid_keys} - + return EntryMetadata(**filtered_meta) except Exception as e: log.warning(f"Error parsing metadata entry: {e}") return EntryMetadata() + class SidecarManager: def __init__(self, directory: Path, watcher, debug: bool = False): self.path = directory / "faststack.json" @@ -57,16 +59,18 @@ def load(self) -> Sidecar: with self.path.open("r") as f: data = json.load(f) json_load_time = time.perf_counter() - t_start - + if self.debug: - log.info(f"SidecarManager.load: loading sidecar took {json_load_time:.3f}s") + log.info( + f"SidecarManager.load: loading sidecar took {json_load_time:.3f}s" + ) if data.get("version") != 2: log.warning("Old sidecar format detected. Starting fresh.") return Sidecar() # Reconstruct nested objects - entries = { - stem: _entrymetadata_from_json(meta) + entries = { + stem: _entrymetadata_from_json(meta) for stem, meta in data.get("entries", {}).items() } return Sidecar( @@ -85,7 +89,11 @@ def save(self): temp_path = self.path.with_suffix(".tmp") was_watcher_running = False try: - if self.watcher and hasattr(self.watcher, 'is_alive') and self.watcher.is_alive(): + if ( + self.watcher + and hasattr(self.watcher, "is_alive") + and self.watcher.is_alive() + ): self.stop_watcher() was_watcher_running = True with temp_path.open("w") as f: @@ -94,13 +102,12 @@ def save(self): "version": self.data.version, "last_index": self.data.last_index, "entries": { - stem: meta.__dict__ - for stem, meta in self.data.entries.items() + stem: meta.__dict__ for stem, meta in self.data.entries.items() }, "stacks": self.data.stacks, } json.dump(serializable_data, f, indent=2) - + # Atomic rename temp_path.replace(self.path) log.debug(f"Saved sidecar file to {self.path}") diff --git a/faststack/io/watcher.py b/faststack/io/watcher.py index 938c169..204acb0 100644 --- a/faststack/io/watcher.py +++ b/faststack/io/watcher.py @@ -9,8 +9,10 @@ log = logging.getLogger(__name__) + class ImageDirectoryEventHandler(FileSystemEventHandler): """Handles filesystem events for the image directory.""" + def __init__(self, callback): super().__init__() self.callback = callback @@ -38,13 +40,15 @@ def on_modified(self, event): # that don't change the content (e.g., antivirus scans). pass + class Watcher: """Manages the filesystem observer.""" + def __init__(self, directory: Path, callback): - self.observer: Optional[Observer] = None # Initialize to None + self.observer: Optional[Observer] = None # Initialize to None self.event_handler = ImageDirectoryEventHandler(callback) self.directory = directory - self.callback = callback # Store callback for new observer + self.callback = callback # Store callback for new observer def start(self): """Starts watching the directory.""" @@ -53,7 +57,7 @@ def start(self): return if self.observer and self.observer.is_alive(): - return # Already running + return # Already running # Create a new observer instance every time, as it cannot be restarted self.observer = Observer() @@ -67,8 +71,8 @@ def stop(self): self.observer.stop() self.observer.join() log.info("Stopped watching directory.") - self.observer = None # Clear instance after stopping + self.observer = None # Clear instance after stopping def is_alive(self) -> bool: """Checks if the watcher thread is alive.""" - return self.observer and self.observer.is_alive() + return bool(self.observer and self.observer.is_alive()) diff --git a/faststack/logging_setup.py b/faststack/logging_setup.py index b16cb4f..ab684a4 100644 --- a/faststack/logging_setup.py +++ b/faststack/logging_setup.py @@ -5,6 +5,7 @@ import os from pathlib import Path + def get_app_data_dir() -> Path: """Returns the application data directory.""" app_data = os.getenv("APPDATA") @@ -12,9 +13,10 @@ def get_app_data_dir() -> Path: return Path(app_data) / "faststack" return Path.home() / ".faststack" + def setup_logging(debug: bool = False): """Sets up logging to a rotating file in the app data directory. - + Args: debug: If True, sets log level to DEBUG. Otherwise, sets to WARNING to reduce noise. """ @@ -24,7 +26,7 @@ def setup_logging(debug: bool = False): # File handler file_handler = logging.handlers.RotatingFileHandler( - log_file, maxBytes=10*1024*1024, backupCount=5 + log_file, maxBytes=10 * 1024 * 1024, backupCount=5 ) formatter = logging.Formatter( "%(asctime)s - %(name)s - %(levelname)s - %(message)s" diff --git a/faststack/models.py b/faststack/models.py index 2562450..2ae3214 100644 --- a/faststack/models.py +++ b/faststack/models.py @@ -4,9 +4,11 @@ from pathlib import Path from typing import Optional, Dict, List + @dataclasses.dataclass class ImageFile: """Represents a single image file on disk.""" + path: Path raw_pair: Optional[Path] = None timestamp: float = 0.0 @@ -35,7 +37,10 @@ def working_tif_path(self) -> Path: @property def has_working_tif(self) -> bool: try: - return self.working_tif_path.exists() and self.working_tif_path.stat().st_size > 0 + return ( + self.working_tif_path.exists() + and self.working_tif_path.stat().st_size > 0 + ) except OSError: return False @@ -50,6 +55,7 @@ def developed_jpg_path(self) -> Path: @dataclasses.dataclass class EntryMetadata: """Sidecar metadata for a single image entry.""" + stack_id: Optional[int] = None stacked: bool = False stacked_date: Optional[str] = None @@ -64,19 +70,22 @@ class EntryMetadata: @dataclasses.dataclass class Sidecar: """Represents the entire sidecar JSON file.""" + version: int = 2 last_index: int = 0 entries: Dict[str, EntryMetadata] = dataclasses.field(default_factory=dict) stacks: List[List[int]] = dataclasses.field(default_factory=list) + @dataclasses.dataclass class DecodedImage: """A decoded image buffer ready for display.""" + buffer: memoryview width: int height: int bytes_per_line: int - format: object # QImage.Format + format: object # QImage.Format def __sizeof__(self) -> int: return self.buffer.nbytes diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 40f39dd..e261cc0 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -3,6 +3,7 @@ import QtQuick.Window import QtQuick.Controls 2.15 import QtQuick.Controls.Material 2.15 import QtQuick.Layouts 1.15 +import QtQuick.Dialogs import "." ApplicationWindow { @@ -14,6 +15,22 @@ ApplicationWindow { minimumHeight: 500 title: "FastStack - " + (uiState ? uiState.currentDirectory : "Loading...") + property bool allowCloseWithRecycleBins: false + + onClosing: function(close) { + if (allowCloseWithRecycleBins) { + close.accepted = true + return + } + if (uiState && uiState.hasRecycleBinItems) { + close.accepted = false + recycleBinCleanupDialog.text = uiState.recycleBinStatsText + recycleBinCleanupDialog.open() + } else { + close.accepted = true + } + } + Component.onCompleted: { // Initialization complete } @@ -771,6 +788,8 @@ ApplicationWindow { id: footerRow spacing: 10 anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right Label { Layout.leftMargin: 10 @@ -910,44 +929,122 @@ ApplicationWindow { Layout.rightMargin: 10 } - // Grid view controls (visible when in grid view) + // Grid view controls (visible when in grid view) - right side Row { visible: uiState && uiState.isGridViewActive - spacing: 8 - Layout.rightMargin: 10 + spacing: 10 + Layout.rightMargin: 15 // Selection info (uses efficient count property, not full list) Label { property int selCount: uiState ? uiState.gridSelectedCount : 0 text: selCount > 0 ? selCount + " selected" : "" color: "#4CAF50" + font.bold: true visible: selCount > 0 anchors.verticalCenter: parent.verticalCenter } // Clear selection button - Button { - text: "Clear" + Rectangle { visible: uiState ? uiState.gridSelectedCount > 0 : false - onClicked: { if (uiState) uiState.gridClearSelection() } - implicitWidth: 60 - implicitHeight: 28 + width: clearLabel.implicitWidth + 16 + height: 26 + radius: 4 + color: clearMouseArea.containsMouse ? "#d32f2f" : "#c62828" + anchors.verticalCenter: parent.verticalCenter + + Label { + id: clearLabel + anchors.centerIn: parent + text: "Clear Selection" + color: "white" + font.pixelSize: 12 + } + + MouseArea { + id: clearMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { if (uiState) uiState.gridClearSelection() } + } + } + + // Back button (only shown when there's history) + Rectangle { + visible: uiState && uiState.gridCanGoBack + width: backLabel.implicitWidth + 16 + height: 26 + radius: 4 + color: backMouseArea.containsMouse ? "#616161" : "#424242" + anchors.verticalCenter: parent.verticalCenter + + Label { + id: backLabel + anchors.centerIn: parent + text: "← Back" + color: "white" + font.pixelSize: 12 + } + + MouseArea { + id: backMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { if (uiState) uiState.gridGoBack() } + } } // Refresh button - Button { - text: "Refresh" - onClicked: { if (uiState) uiState.gridRefresh() } - implicitWidth: 70 - implicitHeight: 28 + Rectangle { + width: refreshLabel.implicitWidth + 16 + height: 26 + radius: 4 + color: refreshMouseArea.containsMouse ? "#1976D2" : "#1565C0" + anchors.verticalCenter: parent.verticalCenter + + Label { + id: refreshLabel + anchors.centerIn: parent + text: "Refresh" + color: "white" + font.pixelSize: 12 + } + + MouseArea { + id: refreshMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { if (uiState) uiState.gridRefresh() } + } } // Single Image View button - Button { - text: "Single Image" - onClicked: { if (uiState) uiState.toggleGridView() } - implicitWidth: 90 - implicitHeight: 28 + Rectangle { + width: singleViewLabel.implicitWidth + 16 + height: 26 + radius: 4 + color: singleViewMouseArea.containsMouse ? "#388E3C" : "#2E7D32" + anchors.verticalCenter: parent.verticalCenter + + Label { + id: singleViewLabel + anchors.centerIn: parent + text: "Single View" + color: "white" + font.pixelSize: 12 + } + + MouseArea { + id: singleViewMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { if (uiState) uiState.toggleGridView() } + } } } } @@ -987,10 +1084,14 @@ ApplicationWindow { "  I: Show EXIF Data
" + "  T: Toggle Thumbnail Grid / Single Image View

" + "Thumbnail Grid View:
" + + "  Arrow Keys: Navigate between images
" + + "  Enter: Open current image in single view
" + + "  Space: Toggle selection on current image
" + "  Click: Open image in single view
" + - "  Ctrl+Click: Toggle selection
" + + "  Right-click / Ctrl+Click: Toggle selection
" + "  Shift+Click: Select range
" + - "  Backspace: Navigate to parent folder
" + + "  B: Add selected images to batch
" + + "  Delete/Backspace: Delete selected or cursor image
" + "  Esc: Clear selection or switch to single view

" + "Viewing:
" + "  Mouse Wheel: Zoom in/out
" + @@ -1038,7 +1139,7 @@ ApplicationWindow { "  P: Edit in Photoshop
" + "  H: Toggle histogram window
" + "  Ctrl+C: Copy image path to clipboard
" + - "  Esc: Close active dialog or editor" + "  Esc: Close dialog/editor, or switch to grid view" padding: 10 wrapMode: Text.WordWrap color: root.currentTextColor @@ -1147,4 +1248,29 @@ ApplicationWindow { color: "black" } } + MessageDialog { + id: recycleBinCleanupDialog + title: "Clean up Recycle Bins?" + buttons: MessageDialog.Yes | MessageDialog.No | MessageDialog.Cancel + + // Custom button text isn't directly supported in standard MessageDialog + // So we interpret: + // Yes -> Delete and Quit + // No -> Quit (Keep Files) + // Cancel -> Don't Quit + + detailedText: "Select 'Yes' to permanently delete these files and quit.\nSelect 'No' to quit but keep files in the recycle bins." + + onButtonClicked: function(button, role) { + if (button === MessageDialog.Yes) { + uiState.cleanupRecycleBins() + allowCloseWithRecycleBins = true + Qt.quit() + } else if (button === MessageDialog.No) { + allowCloseWithRecycleBins = true + Qt.quit() + } + // Cancel does nothing, just closes dialog + } + } } diff --git a/faststack/qml/ThumbnailGridView.qml b/faststack/qml/ThumbnailGridView.qml index a1d804d..6829b7f 100644 --- a/faststack/qml/ThumbnailGridView.qml +++ b/faststack/qml/ThumbnailGridView.qml @@ -21,11 +21,18 @@ Item { GridView { id: thumbnailGrid anchors.fill: parent - anchors.margins: 8 + anchors.leftMargin: 8 + anchors.rightMargin: 8 + anchors.topMargin: 8 + anchors.bottomMargin: 40 // Extra space for status bar cellWidth: gridViewRoot.cellWidth cellHeight: gridViewRoot.cellHeight clip: true + focus: true + keyNavigationEnabled: false // We handle all navigation in Keys.onPressed + highlightFollowsCurrentItem: true + currentIndex: 0 // Track cursor position model: thumbnailModel @@ -52,6 +59,7 @@ Item { tileFolderStats: folderStats || null tileIsSelected: isSelected || false tileIsParentFolder: isParentFolder || false + tileHasCursor: index === thumbnailGrid.currentIndex } // Scroll bar @@ -89,8 +97,8 @@ Item { if (topIndex < 0) topIndex = 0 if (bottomIndex < 0) bottomIndex = thumbnailGrid.count - 1 - // Add margin - var cols = Math.floor(thumbnailGrid.width / thumbnailGrid.cellWidth) + // Add margin (with epsilon to handle sub-pixel rounding during resize) + var cols = Math.floor((thumbnailGrid.width + 1) / thumbnailGrid.cellWidth) if (cols < 1) cols = 1 var marginItems = cols * thumbnailGrid.prefetchMargin topIndex = Math.max(0, topIndex - marginItems) @@ -109,10 +117,14 @@ Item { // Trigger prefetch when model count changes (initial load) onCountChanged: { - if (count > 0) { - // Small delay to let the view layout - prefetchTimer.restart() + if (count <= 0) { + currentIndex = 0 + return } + if (currentIndex >= count) { + currentIndex = count - 1 + } + prefetchTimer.restart() } // Empty state @@ -123,41 +135,75 @@ Item { color: gridViewRoot.isDarkTheme ? "#888888" : "#666666" font.pixelSize: 16 } - } - // Keyboard shortcuts - Keys.onPressed: function(event) { - if (event.key === Qt.Key_Escape) { - // Clear selection or switch to loupe + // Keyboard shortcuts (inside GridView so it receives focus) + Keys.onPressed: function(event) { if (!uiState) return - if (gridViewRoot.selectedCount > 0) { - uiState.gridClearSelection() - } else { - uiState.toggleGridView() - } - event.accepted = true - } else if (event.key === Qt.Key_Backspace) { - // Navigate to parent - if (!uiState) return - var model = thumbnailModel - if (model && model.rowCount() > 0) { - // Check if first item is parent folder - var firstEntry = model.data(model.index(0, 0), 259) // IsFolderRole - var isParent = model.data(model.index(0, 0), 269) // IsParentFolderRole - if (firstEntry && isParent) { - var parentPath = model.data(model.index(0, 0), 257) // FilePathRole - if (parentPath) { - uiState.gridNavigateTo(parentPath) - } + + // Calculate columns with epsilon to handle rounding issues during window resizing + var cols = Math.max(1, Math.floor((thumbnailGrid.width + 1) / thumbnailGrid.cellWidth)) + + if (event.key === Qt.Key_Escape) { + // Clear selection or switch to loupe + if (gridViewRoot.selectedCount > 0) { + uiState.gridClearSelection() + } else { + uiState.toggleGridView() } + event.accepted = true + } else if (event.key === Qt.Key_Left) { + // Move cursor left + if (thumbnailGrid.currentIndex > 0) { + thumbnailGrid.currentIndex-- + thumbnailGrid.positionViewAtIndex(thumbnailGrid.currentIndex, GridView.Contain) + } + event.accepted = true + } else if (event.key === Qt.Key_Right) { + // Move cursor right + if (thumbnailGrid.currentIndex < thumbnailGrid.count - 1) { + thumbnailGrid.currentIndex++ + thumbnailGrid.positionViewAtIndex(thumbnailGrid.currentIndex, GridView.Contain) + } + event.accepted = true + } else if (event.key === Qt.Key_Up) { + // Move cursor up one row + var newIndex = thumbnailGrid.currentIndex - cols + if (newIndex >= 0) { + thumbnailGrid.currentIndex = newIndex + thumbnailGrid.positionViewAtIndex(thumbnailGrid.currentIndex, GridView.Contain) + } + event.accepted = true + } else if (event.key === Qt.Key_Down) { + // Move cursor down one row + var newIndex = thumbnailGrid.currentIndex + cols + if (newIndex < thumbnailGrid.count) { + thumbnailGrid.currentIndex = newIndex + thumbnailGrid.positionViewAtIndex(thumbnailGrid.currentIndex, GridView.Contain) + } + event.accepted = true + } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + // Open current item in loupe view (or navigate into folder) + uiState.gridOpenIndex(thumbnailGrid.currentIndex) + event.accepted = true + } else if (event.key === Qt.Key_Space) { + // Toggle selection on current item + uiState.gridSelectIndex(thumbnailGrid.currentIndex, false, true) + event.accepted = true + } else if (event.key === Qt.Key_B) { + // Add selected images to batch + uiState.gridAddSelectionToBatch() + event.accepted = true + } else if (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) { + // Delete selected images or cursor image + uiState.gridDeleteAtCursor(thumbnailGrid.currentIndex) + event.accepted = true } - event.accepted = true } } // Focus handling Component.onCompleted: { - gridViewRoot.forceActiveFocus() + thumbnailGrid.forceActiveFocus() // Trigger initial prefetch after a short delay initialPrefetchTimer.start() } @@ -179,7 +225,16 @@ Item { if (uiState.isGridViewActive) { // Trigger prefetch when grid view becomes active thumbnailGrid.triggerPrefetch() - gridViewRoot.forceActiveFocus() + thumbnailGrid.forceActiveFocus() + } + } + function onGridScrollToIndex(index) { + // Scroll to show the current loupe image when entering grid view + if (index >= 0 && index < thumbnailGrid.count) { + // Move cursor to match the loupe image + thumbnailGrid.currentIndex = index + // Scroll to center it in the view + thumbnailGrid.positionViewAtIndex(index, GridView.Center) } } } diff --git a/faststack/qml/ThumbnailTile.qml b/faststack/qml/ThumbnailTile.qml index 7c13898..4f9f77e 100644 --- a/faststack/qml/ThumbnailTile.qml +++ b/faststack/qml/ThumbnailTile.qml @@ -21,6 +21,7 @@ Item { property var tileFolderStats: null property bool tileIsSelected: false property bool tileIsParentFolder: false + property bool tileHasCursor: false // Keyboard cursor position // Theme property (bound by parent) property bool isDarkTheme: false @@ -44,6 +45,12 @@ Item { property color editedColor: "#FFEB3B" // Yellow for edited (E) property color restackedColor: "#FF9800" // Orange for restacked (R) property color batchColor: "#2196F3" // Blue for batch (B) + property color cursorColor: "#00BFFF" // Cyan for keyboard cursor + property color loadingColor: tile.isDarkTheme ? "#3c3c3c" : "#e0e0e0" + property color counterUploadedCol: "#7BBF7F" // Muted green + property color counterStackedCol: "#E8A64C" // Muted orange + property color counterEditedCol: "#E8D44C" // Muted yellow + property color emptyTextColor: tile.isDarkTheme ? "#888888" : "#666666" // Background Rectangle { @@ -53,6 +60,8 @@ Item { return Qt.rgba(currentColor.r, currentColor.g, currentColor.b, 0.25) } else if (tile.tileIsSelected) { return Qt.rgba(selectedColor.r, selectedColor.g, selectedColor.b, 0.3) + } else if (tile.tileHasCursor) { + return Qt.rgba(cursorColor.r, cursorColor.g, cursorColor.b, 0.15) } else if (tileMouseArea.containsMouse) { return hoverColor } @@ -60,16 +69,18 @@ Item { } radius: 4 - // Border - current gets gold, selected gets green + // Border - current gets gold, selected gets green, cursor gets cyan border.color: { if (tile.tileIsCurrent && !tile.tileIsFolder) { return currentColor } else if (tile.tileIsSelected) { return selectedColor + } else if (tile.tileHasCursor) { + return cursorColor } return "transparent" } - border.width: (tile.tileIsCurrent || tile.tileIsSelected) && !tile.tileIsFolder ? 3 : 0 + border.width: (tile.tileIsCurrent || tile.tileIsSelected || tile.tileHasCursor) && !tile.tileIsFolder ? 3 : (tile.tileHasCursor && tile.tileIsFolder ? 2 : 0) } // Content column @@ -100,7 +111,7 @@ Item { Rectangle { anchors.fill: parent visible: thumbnailImage.status === Image.Loading - color: tile.isDarkTheme ? "#3c3c3c" : "#e0e0e0" + color: tile.loadingColor BusyIndicator { anchors.centerIn: parent @@ -111,13 +122,13 @@ Item { } } - // Folder icon overlay (for folders without faststack.json) + // Folder icon overlay (flat folder icon for dark mode) Text { anchors.centerIn: parent visible: tile.tileIsFolder && !tile.tileIsParentFolder - text: "\uD83D\uDCC1" // Folder emoji - font.pixelSize: 48 - opacity: 0.8 + text: "\uD83D\uDDC2" // File cabinet / open folder emoji (cleaner look) + font.pixelSize: 44 + opacity: 0.7 } // Parent folder indicator @@ -219,52 +230,218 @@ Item { } } - // Folder stats overlay (for folders with faststack.json) - Rectangle { - anchors.bottom: parent.bottom + // ============================================================ + // TOP STATS OVERLAY: U (left), S (center), E (right) + // Colored text: U=green, S=orange, E=yellow + // Thin top scrim for readability + // ============================================================ + Item { + id: topStatsOverlay + anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - height: tile.tileFolderStats && tile.tileFolderStats.total_images > 0 ? 36 : 0 - color: Qt.rgba(0, 0, 0, 0.7) + height: 22 visible: tile.tileIsFolder && tile.tileFolderStats && tile.tileFolderStats.total_images > 0 - Column { - anchors.centerIn: parent - spacing: 2 + // Thin top scrim gradient + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.35) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.0) } + } + } + + // Shared font for tabular numerals + property string numFont: "Consolas, Monaco, monospace" + property int numSize: 10 + // Muted colors for counters + property color uploadedCol: tile.counterUploadedCol + property color stackedCol: tile.counterStackedCol + property color editedCol: tile.counterEditedCol + // Letter slightly dimmer than number + property real letterOpacity: 0.85 + property real numberOpacity: 1.0 + // U counter (top-left, always shown) + Row { + anchors.left: parent.left + anchors.top: parent.top + anchors.leftMargin: 8 + anchors.topMargin: 5 + spacing: 3 Text { - anchors.horizontalCenter: parent.horizontalCenter - text: tile.tileFolderStats ? tile.tileFolderStats.total_images + " images" : "" - font.pixelSize: 10 - font.bold: true - color: "white" + text: "U" + font.pixelSize: topStatsOverlay.numSize + font.weight: Font.Medium + font.family: topStatsOverlay.numFont + color: topStatsOverlay.uploadedCol + opacity: topStatsOverlay.letterOpacity } + Text { + text: tile.tileFolderStats ? tile.tileFolderStats.uploaded_count.toString() : "0" + font.pixelSize: topStatsOverlay.numSize + font.weight: Font.DemiBold + font.family: topStatsOverlay.numFont + color: topStatsOverlay.uploadedCol + opacity: topStatsOverlay.numberOpacity + } + } - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 6 - visible: tile.tileFolderStats && (tile.tileFolderStats.stacked_count > 0 || tile.tileFolderStats.uploaded_count > 0 || tile.tileFolderStats.edited_count > 0) + // S counter (top-center, only if stacked_count > 0) + Row { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 5 + spacing: 3 + visible: tile.tileFolderStats && tile.tileFolderStats.stacked_count > 0 + Text { + text: "S" + font.pixelSize: topStatsOverlay.numSize + font.weight: Font.Medium + font.family: topStatsOverlay.numFont + color: topStatsOverlay.stackedCol + opacity: topStatsOverlay.letterOpacity + } + Text { + text: tile.tileFolderStats ? tile.tileFolderStats.stacked_count.toString() : "0" + font.pixelSize: topStatsOverlay.numSize + font.weight: Font.DemiBold + font.family: topStatsOverlay.numFont + color: topStatsOverlay.stackedCol + opacity: topStatsOverlay.numberOpacity + } + } - Text { - visible: tile.tileFolderStats && tile.tileFolderStats.stacked_count > 0 - text: "S:" + (tile.tileFolderStats ? tile.tileFolderStats.stacked_count : 0) - font.pixelSize: 9 - color: "#FF9800" - } - Text { - visible: tile.tileFolderStats && tile.tileFolderStats.uploaded_count > 0 - text: "U:" + (tile.tileFolderStats ? tile.tileFolderStats.uploaded_count : 0) - font.pixelSize: 9 - color: "#4CAF50" - } - Text { - visible: tile.tileFolderStats && tile.tileFolderStats.edited_count > 0 - text: "E:" + (tile.tileFolderStats ? tile.tileFolderStats.edited_count : 0) - font.pixelSize: 9 - color: "#FFEB3B" + // E counter (top-right, only if edited_count > 0) + Row { + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: 8 + anchors.topMargin: 5 + spacing: 3 + visible: tile.tileFolderStats && tile.tileFolderStats.edited_count > 0 + Text { + text: "E" + font.pixelSize: topStatsOverlay.numSize + font.weight: Font.Medium + font.family: topStatsOverlay.numFont + color: topStatsOverlay.editedCol + opacity: topStatsOverlay.letterOpacity + } + Text { + text: tile.tileFolderStats ? tile.tileFolderStats.edited_count.toString() : "0" + font.pixelSize: topStatsOverlay.numSize + font.weight: Font.DemiBold + font.family: topStatsOverlay.numFont + color: topStatsOverlay.editedCol + opacity: topStatsOverlay.numberOpacity + } + } + } + + // ============================================================ + // BOTTOM OVERLAY: Sparkline + Centered file counts + // ============================================================ + Item { + id: bottomOverlay + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 38 + visible: tile.tileIsFolder && tile.tileFolderStats && tile.tileFolderStats.total_images > 0 + + // Subtle 3-stop gradient scrim (starts at ~80%) + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.0; color: Qt.rgba(0, 0, 0, 0.0) } + GradientStop { position: 0.4; color: Qt.rgba(0, 0, 0, 0.20) } + GradientStop { position: 1.0; color: Qt.rgba(0, 0, 0, 0.55) } + } + } + + // Shared font for tabular numerals + property string numFont: "Consolas, Monaco, monospace" + property int numSize: 11 + + // Coverage sparkline (dual-channel: upload green, stack orange) + Row { + id: sparklineRow + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: countsRow.top + anchors.bottomMargin: 4 + spacing: 1 + visible: tile.tileFolderStats && tile.tileFolderStats.coverage_buckets && tile.tileFolderStats.coverage_buckets.length > 0 + + Repeater { + model: tile.tileFolderStats && tile.tileFolderStats.coverage_buckets ? tile.tileFolderStats.coverage_buckets : [] + + delegate: Column { + spacing: 1 + // Upload bar (green) - top + Rectangle { + width: 3 + height: 2 + radius: 0.5 + color: tile.counterUploadedCol + opacity: modelData[0] * 0.9 + 0.1 // 0.1 base opacity, up to 1.0 + } + // Stack bar (orange) - bottom + Rectangle { + width: 3 + height: 2 + radius: 0.5 + color: tile.counterStackedCol + opacity: modelData[1] * 0.9 + 0.1 // 0.1 base opacity, up to 1.0 + } } } } + + // File counts: "{jpg_count} JPG · {raw_count} RAW" (centered, always show both) + Row { + id: countsRow + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 6 + spacing: 0 + + Text { + text: tile.tileFolderStats ? tile.tileFolderStats.jpg_count.toString() : "0" + font.pixelSize: bottomOverlay.numSize + font.weight: Font.DemiBold + font.family: bottomOverlay.numFont + color: "#FFFFFF" + } + Text { + text: " IMG" + font.pixelSize: bottomOverlay.numSize + font.weight: Font.Medium + font.family: bottomOverlay.numFont + color: Qt.rgba(1, 1, 1, 0.85) + } + Text { + text: " · " + font.pixelSize: bottomOverlay.numSize + font.weight: Font.Medium + color: Qt.rgba(1, 1, 1, 0.5) + } + Text { + text: tile.tileFolderStats ? tile.tileFolderStats.raw_count.toString() : "0" + font.pixelSize: bottomOverlay.numSize + font.weight: Font.DemiBold + font.family: bottomOverlay.numFont + color: "#FFFFFF" + } + Text { + text: " RAW" + font.pixelSize: bottomOverlay.numSize + font.weight: Font.Medium + font.family: bottomOverlay.numFont + color: Qt.rgba(1, 1, 1, 0.85) + } + } } } @@ -286,7 +463,7 @@ Item { id: tileMouseArea anchors.fill: parent hoverEnabled: true - acceptedButtons: Qt.LeftButton + acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: function(mouse) { if (tile.tileIsFolder) { @@ -296,12 +473,16 @@ Item { // Handle selection or opening var hasShift = (mouse.modifiers & Qt.ShiftModifier) var hasCtrl = (mouse.modifiers & Qt.ControlModifier) + var isRightClick = (mouse.button === Qt.RightButton) - if (hasShift || hasCtrl) { - // Batch selection + if (isRightClick) { + // Right-click: toggle selection (as per help text) + uiState.gridSelectIndex(tile.tileIndex, false, true) + } else if (hasShift || hasCtrl) { + // Shift: range select, Ctrl: add to selection uiState.gridSelectIndex(tile.tileIndex, hasShift, hasCtrl) } else { - // Open in loupe view + // Left-click without modifiers: open in loupe view uiState.gridOpenIndex(tile.tileIndex) } } diff --git a/faststack/test_pil_blur.py b/faststack/test_pil_blur.py index 290c501..2a424f9 100644 --- a/faststack/test_pil_blur.py +++ b/faststack/test_pil_blur.py @@ -1,28 +1,29 @@ - from PIL import Image, ImageFilter import numpy as np import time + def test_blur(): try: # Create a dummy float image data = np.random.rand(100, 100).astype(np.float32) - img = Image.fromarray(data, mode='F') - + img = Image.fromarray(data, mode="F") + print("Attempting blur on mode 'F'...") start = time.time() blurred = img.filter(ImageFilter.GaussianBlur(radius=5)) print(f"Blur took {time.time() - start:.4f}s") - + result = np.array(blurred) print(f"Result shape: {result.shape}, dtype: {result.dtype}") - + # Check if it actually blurred (simple check: std dev should decrease) print(f"Original std: {np.std(data):.4f}") print(f"Blurred std: {np.std(result):.4f}") - + except Exception as e: print(f"Failed: {e}") + if __name__ == "__main__": test_blur() diff --git a/faststack/tests/benchmark_decode.py b/faststack/tests/benchmark_decode.py index 19ead5a..29cbd51 100644 --- a/faststack/tests/benchmark_decode.py +++ b/faststack/tests/benchmark_decode.py @@ -1,10 +1,10 @@ - import time import io import numpy as np from PIL import Image from faststack.imaging.jpeg import decode_jpeg_resized, TURBO_AVAILABLE + def create_test_jpeg(width=6000, height=4000): """Creates a large test JPEG in memory.""" print(f"Creating test JPEG ({width}x{height})...") @@ -15,6 +15,7 @@ def create_test_jpeg(width=6000, height=4000): img.save(buf, format="JPEG", quality=90) return buf.getvalue() + def benchmark(): jpeg_bytes = create_test_jpeg() print(f"JPEG size: {len(jpeg_bytes) / 1024 / 1024:.2f} MB") @@ -34,7 +35,8 @@ def benchmark(): avg_time = (end - start) / iterations print(f"Average decode time (Current Implementation): {avg_time:.4f} s") - print(f"FPS: {1/avg_time:.2f}") + print(f"FPS: {1 / avg_time:.2f}") + if __name__ == "__main__": benchmark() diff --git a/faststack/tests/benchmark_decode_bilinear.py b/faststack/tests/benchmark_decode_bilinear.py index 12cb58a..97c76ef 100644 --- a/faststack/tests/benchmark_decode_bilinear.py +++ b/faststack/tests/benchmark_decode_bilinear.py @@ -1,9 +1,15 @@ - import time import io import numpy as np from PIL import Image -from faststack.imaging.jpeg import decode_jpeg_rgb, _get_turbojpeg_scaling_factor, TURBO_AVAILABLE, jpeg_decoder, TJPF_RGB +from faststack.imaging.jpeg import ( + decode_jpeg_rgb, + _get_turbojpeg_scaling_factor, + TURBO_AVAILABLE, + jpeg_decoder, + TJPF_RGB, +) + def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): """Decodes and resizes a JPEG to fit within the given dimensions using BILINEAR.""" @@ -22,15 +28,15 @@ def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): max_dim = height scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max_dim) - + if scale_factor: decoded = jpeg_decoder.decode( - jpeg_bytes, + jpeg_bytes, scaling_factor=scale_factor, - pixel_format=TJPF_RGB, - flags=0 + pixel_format=TJPF_RGB, + flags=0, ) - + # Only use Pillow for final resize if needed if decoded.shape[0] > height or decoded.shape[1] > width: img = Image.fromarray(decoded) @@ -40,7 +46,7 @@ def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): return decoded except Exception as e: print(f"PyTurboJPEG failed: {e}") - + # Fallback to Pillow try: img = Image.open(io.BytesIO(jpeg_bytes)) @@ -50,6 +56,7 @@ def decode_jpeg_resized_bilinear(jpeg_bytes: bytes, width: int, height: int): print(f"Pillow failed: {e}") return None + def create_test_jpeg(width=6000, height=4000): """Creates a large test JPEG in memory.""" print(f"Creating test JPEG ({width}x{height})...") @@ -59,6 +66,7 @@ def create_test_jpeg(width=6000, height=4000): img.save(buf, format="JPEG", quality=90) return buf.getvalue() + def benchmark(): jpeg_bytes = create_test_jpeg() print(f"JPEG size: {len(jpeg_bytes) / 1024 / 1024:.2f} MB") @@ -78,7 +86,8 @@ def benchmark(): avg_time = (end - start) / iterations print(f"Average decode time (BILINEAR): {avg_time:.4f} s") - print(f"FPS: {1/avg_time:.2f}") + print(f"FPS: {1 / avg_time:.2f}") + if __name__ == "__main__": benchmark() diff --git a/faststack/tests/check_imports.py b/faststack/tests/check_imports.py index 84d9842..3204e45 100644 --- a/faststack/tests/check_imports.py +++ b/faststack/tests/check_imports.py @@ -6,18 +6,32 @@ try: print("Importing faststack.app...") - from faststack.app import AppController + import faststack.app + print("Success faststack.app") +except ImportError as e: + print(f"ImportError faststack.app: {e}") + import traceback + + traceback.print_exc() except Exception as e: - print(f"Failed faststack.app: {e}") + print(f"Non-ImportError during import of faststack.app: {e}") import traceback + traceback.print_exc() try: print("Importing faststack.tests.test_raw_pipeline...") import faststack.tests.test_raw_pipeline + print("Success test_raw_pipeline") +except ImportError as e: + print(f"ImportError test_raw_pipeline: {e}") + import traceback + + traceback.print_exc() except Exception as e: - print(f"Failed test_raw_pipeline: {e}") + print(f"Non-ImportError during import of test_raw_pipeline: {e}") import traceback + traceback.print_exc() diff --git a/faststack/tests/check_turbo.py b/faststack/tests/check_turbo.py index 1fb0afc..4805897 100644 --- a/faststack/tests/check_turbo.py +++ b/faststack/tests/check_turbo.py @@ -1,9 +1,9 @@ - try: import turbojpeg + print("turbojpeg module found") print(f"Dir: {dir(turbojpeg)}") - if hasattr(turbojpeg, 'TJFLAG_FASTDCT'): + if hasattr(turbojpeg, "TJFLAG_FASTDCT"): print(f"TJFLAG_FASTDCT: {turbojpeg.TJFLAG_FASTDCT}") else: print("TJFLAG_FASTDCT not found in module") diff --git a/faststack/tests/debug_metadata.py b/faststack/tests/debug_metadata.py index d2c0dea..6e94086 100644 --- a/faststack/tests/debug_metadata.py +++ b/faststack/tests/debug_metadata.py @@ -1,4 +1,3 @@ - import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -10,18 +9,21 @@ from faststack.imaging.metadata import get_exif_data + def debug_test(): with open("debug_output.txt", "w") as f: f.write("Starting debug test...\n") try: # Patch PIL.Image.open directly - with patch('PIL.Image.open') as mock_open, \ - patch('pathlib.Path.exists', return_value=True): + with ( + patch("PIL.Image.open") as mock_open, + patch("pathlib.Path.exists", return_value=True), + ): # Setup mock image and exif data mock_img = MagicMock() - + tag_map = {v: k for k, v in ExifTags.TAGS.items()} - + exif_dict = { tag_map["DateTimeOriginal"]: "2023:01:01 12:00:00", tag_map["Make"]: "Canon", @@ -32,15 +34,17 @@ def debug_test(): tag_map["ExposureTime"]: (1, 200), tag_map["FocalLength"]: (50, 1), } - + mock_img._getexif.return_value = exif_dict mock_open.return_value = mock_img - + f.write("Calling get_exif_data...\n") result = get_exif_data(Path("dummy.jpg")) - f.write(f"Result Summary: {json.dumps(result.get('summary', {}), indent=2)}\n") + f.write( + f"Result Summary: {json.dumps(result.get('summary', {}), indent=2)}\n" + ) f.write(f"Result Full Keys: {list(result.get('full', {}).keys())}\n") - + summary = result["summary"] assert summary["Date Taken"] == "2023:01:01 12:00:00" assert summary["Camera"] == "Canon EOS R5" @@ -49,12 +53,14 @@ def debug_test(): assert summary["Aperture"] == "f/2.8" assert summary["Shutter Speed"] == "1/200s" assert summary["Focal Length"] == "50mm" - + f.write("Test PASSED\n") - except Exception as e: + except Exception: f.write("Test FAILED\n") import traceback + traceback.print_exc(file=f) + if __name__ == "__main__": debug_test() diff --git a/faststack/tests/manual_test_error_handling.py b/faststack/tests/manual_test_error_handling.py index 3575d57..4edb2dd 100644 --- a/faststack/tests/manual_test_error_handling.py +++ b/faststack/tests/manual_test_error_handling.py @@ -1,40 +1,41 @@ - import sys -import unittest from unittest.mock import MagicMock, patch import numpy as np from pathlib import Path -import os import logging # Configure logging to swallow output logging.basicConfig(level=logging.CRITICAL) + def test_load_image_raises(): print("Running test_load_image_raises...") try: from faststack.imaging.editor import ImageEditor + editor = ImageEditor() - + # Patch Image.open to raise an exception - with patch('PIL.Image.open', side_effect=OSError("Mocked file error")): - with patch.dict(sys.modules, {'cv2': MagicMock()}): - sys.modules['cv2'].imread.return_value = None - - try: - editor.load_image("non_existent_file.jpg") - print("FAILURE: load_image did NOT raise exception") - return False - except OSError as e: - if "Mocked file error" in str(e): - print("SUCCESS: load_image raised expected exception") - return True - else: - print(f"FAILURE: load_image raised wrong exception: {e}") - return False - except Exception as e: - print(f"FAILURE: load_image raised unexpected exception type: {type(e)} {e}") - return False + with patch("PIL.Image.open", side_effect=OSError("Mocked file error")): + with patch.dict(sys.modules, {"cv2": MagicMock()}): + sys.modules["cv2"].imread.return_value = None + + try: + editor.load_image("non_existent_file.jpg") + print("FAILURE: load_image did NOT raise exception") + return False + except OSError as e: + if "Mocked file error" in str(e): + print("SUCCESS: load_image raised expected exception") + return True + else: + print(f"FAILURE: load_image raised wrong exception: {e}") + return False + except Exception as e: + print( + f"FAILURE: load_image raised unexpected exception type: {type(e)} {e}" + ) + return False except ImportError as e: print(f"ImportError in test setup: {e}") return False @@ -42,49 +43,59 @@ def test_load_image_raises(): print(f"Unexpected error in test setup: {e}") return False + def test_save_image_raises(): print("Running test_save_image_raises...") try: from faststack.imaging.editor import ImageEditor + editor = ImageEditor() editor.float_image = np.zeros((10, 10, 3), dtype=np.float32) editor.current_filepath = Path("fake_path.jpg") - - with patch('faststack.imaging.editor.create_backup_file', return_value=Path("backup.jpg")): - mock_img = MagicMock() - # fail ANY save call - mock_img.save.side_effect = PermissionError("Mocked save error") - - with patch('PIL.Image.fromarray', return_value=mock_img): - try: - editor.save_image() - print("FAILURE: save_image did NOT raise exception") - return False - except PermissionError as e: - if "Mocked save error" in str(e): - print("SUCCESS: save_image raised expected exception") - return True - else: - print(f"FAILURE: save_image raised wrong exception: {e}") - return False - except Exception as e: - print(f"FAILURE: save_image raised unexpected exception type: {type(e)} {e}") - return False + + with patch( + "faststack.imaging.editor.create_backup_file", + return_value=Path("backup.jpg"), + ): + mock_img = MagicMock() + # fail ANY save call + mock_img.save.side_effect = PermissionError("Mocked save error") + + with patch("PIL.Image.fromarray", return_value=mock_img): + try: + editor.save_image() + print("FAILURE: save_image did NOT raise exception") + return False + except PermissionError as e: + if "Mocked save error" in str(e): + print("SUCCESS: save_image raised expected exception") + return True + else: + print(f"FAILURE: save_image raised wrong exception: {e}") + return False + except Exception as e: + print( + f"FAILURE: save_image raised unexpected exception type: {type(e)} {e}" + ) + return False except Exception as e: print(f"Unexpected error in test setup: {e}") return False -if __name__ == '__main__': + +if __name__ == "__main__": # Ensure parent path in sys.path root_dir = Path(__file__).parent.parent.parent if str(root_dir) not in sys.path: sys.path.insert(0, str(root_dir)) - + success = True - if not test_load_image_raises(): success = False + if not test_load_image_raises(): + success = False print("-" * 20) - if not test_save_image_raises(): success = False - + if not test_save_image_raises(): + success = False + if not success: sys.exit(1) print("ALL TESTS PASSED") diff --git a/faststack/tests/mini_test.py b/faststack/tests/mini_test.py index 2c42aec..2e30d13 100644 --- a/faststack/tests/mini_test.py +++ b/faststack/tests/mini_test.py @@ -1,4 +1,3 @@ - import sys from unittest.mock import MagicMock, patch import os @@ -7,8 +6,9 @@ print("START_TEST") try: from faststack.imaging.editor import ImageEditor + editor = ImageEditor() - + # Test 1: Missing file raises FileNotFoundError print("Test 1: Missing file...") try: @@ -23,21 +23,21 @@ print("Test 2: Bad file load...") with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp: tmp_name = tmp.name - + try: - with patch('PIL.Image.open', side_effect=OSError("FAIL_PIL")): - with patch.dict(sys.modules, {'cv2': MagicMock()}): - sys.modules['cv2'].imread.return_value = None - try: - editor.load_image(tmp_name) - print("FAIL 2: No exception raised for bad load") - except OSError as e: - if "FAIL_PIL" in str(e): - print("PASS 2: Caught expected OSError") - else: - print(f"FAIL 2: Wrong error: {e}") - except Exception as e: - print(f"FAIL 2: Unexpected exception: {type(e)} {e}") + with patch("PIL.Image.open", side_effect=OSError("FAIL_PIL")): + with patch.dict(sys.modules, {"cv2": MagicMock()}): + sys.modules["cv2"].imread.return_value = None + try: + editor.load_image(tmp_name) + print("FAIL 2: No exception raised for bad load") + except OSError as e: + if "FAIL_PIL" in str(e): + print("PASS 2: Caught expected OSError") + else: + print(f"FAIL 2: Wrong error: {e}") + except Exception as e: + print(f"FAIL 2: Unexpected exception: {type(e)} {e}") finally: if os.path.exists(tmp_name): os.remove(tmp_name) diff --git a/faststack/tests/reproduce_exif_bug.py b/faststack/tests/reproduce_exif_bug.py index 828e16d..cbfce5f 100644 --- a/faststack/tests/reproduce_exif_bug.py +++ b/faststack/tests/reproduce_exif_bug.py @@ -3,8 +3,10 @@ from unittest.mock import MagicMock # Add parent directory to path for standalone execution -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) -sys.modules['cv2'] = MagicMock() +sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +) +sys.modules["cv2"] = MagicMock() import unittest from unittest.mock import patch @@ -12,43 +14,47 @@ from faststack.imaging.editor import ImageEditor + class TestExifReproduction(unittest.TestCase): def setUp(self): self.editor = ImageEditor() # Create a dummy image for testing - self.editor.original_image = Image.new('RGB', (10, 10)) + self.editor.original_image = Image.new("RGB", (10, 10)) self.editor._source_exif_bytes = b"original source exif" def test_tobytes_failure_drops_exif(self): """Verify that a failure in tobytes() currently drops EXIF data.""" mock_exif = MagicMock() mock_exif.tobytes.side_effect = Exception("failed to serialize") - + # Patch Image.Exif to return our mock - with patch('PIL.Image.Exif', return_value=mock_exif): + with patch("PIL.Image.Exif", return_value=mock_exif): res = self.editor._get_sanitized_exif_bytes() - + # DESIRED BEHAVIOR: It returns the original bytes if sanitization fails self.assertEqual(res, b"original source exif") - + def test_missing_tobytes_drops_exif(self): """Verify that missing tobytes() currently drops EXIF data.""" - mock_exif = MagicMock(spec=[]) # No tobytes - - with patch('PIL.Image.Exif', return_value=mock_exif): + mock_exif = MagicMock(spec=[]) # No tobytes + + with patch("PIL.Image.Exif", return_value=mock_exif): res = self.editor._get_sanitized_exif_bytes() - # DESIRED BEHAVIOR: It returns the original bytes + # DESIRED BEHAVIOR: It returns the original bytes self.assertEqual(res, b"original source exif") -if __name__ == '__main__': + +if __name__ == "__main__": suite = unittest.TestLoader().loadTestsFromTestCase(TestExifReproduction) result = unittest.TestResult() suite.run(result) - + if result.wasSuccessful(): print("Success!") else: - print(f"FAILED with {len(result.failures)} failures and {len(result.errors)} errors") + print( + f"FAILED with {len(result.failures)} failures and {len(result.errors)} errors" + ) for f in result.failures: print("FAILURE in", f[0]) print(f[1]) diff --git a/faststack/tests/run_loading_tests.py b/faststack/tests/run_loading_tests.py index 4d37940..4a549c5 100644 --- a/faststack/tests/run_loading_tests.py +++ b/faststack/tests/run_loading_tests.py @@ -1,5 +1,5 @@ """Debug script to run tests and capture full output.""" -import sys + import os # Change to faststack directory @@ -7,8 +7,9 @@ # Run the test import unittest + loader = unittest.TestLoader() -suite = loader.discover('.', pattern='test_editor_loading.py') +suite = loader.discover(".", pattern="test_editor_loading.py") runner = unittest.TextTestRunner(verbosity=2) result = runner.run(suite) diff --git a/faststack/tests/test_auto_levels.py b/faststack/tests/test_auto_levels.py index 75ccf95..114380c 100644 --- a/faststack/tests/test_auto_levels.py +++ b/faststack/tests/test_auto_levels.py @@ -1,5 +1,3 @@ - -import pytest import numpy as np from PIL import Image from faststack.imaging.editor import ImageEditor @@ -11,127 +9,130 @@ def test_auto_levels_pins_highlights_if_clipped(): w, h = 10, 10 arr = np.zeros((h, w, 3), dtype=np.uint8) arr[:] = 100 - + # Clip Blue: Set last pixel to 255 arr[9, 9, 2] = 255 - - img = Image.fromarray(arr, 'RGB') + + img = Image.fromarray(arr, "RGB") editor.original_image = img editor._preview_image = img - + # Use threshold 0.0 to make p_low deterministic (min value) # This prevents fragility with per-channel percentiles on small arrays blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) - + # 1 pixel of 255 in 100 is 1%. Eps (from threshold 0.0) would be 0.0. # Actually logic is eps = min(threshold, 0.01). If threshold 0.0, eps=0.0. # 1% > 0.0% -> Pins. - + assert p_high == 255.0 assert whites == 0.0 - + # p_low should be the strict minimum (100) assert p_low == 100.0 + def test_auto_levels_pins_shadows_if_clipped(): editor = ImageEditor() w, h = 10, 10 arr = np.zeros((h, w, 3), dtype=np.uint8) arr[:] = 100 - + # Clip Red shadow: 1 pixel at 0. arr[0, 0, 0] = 0 - - img = Image.fromarray(arr, 'RGB') + + img = Image.fromarray(arr, "RGB") editor.original_image = img editor._preview_image = img - + # Threshold 0.0 -> eps=0.0. 1% detected > 0.0 -> Pins. blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) - - assert p_low == 0.0 + + assert p_low == 0.0 assert blacks == 0.0 - + # Whites should be normal (max is 100) assert p_high == 100.0 assert whites == (255.0 - 100.0) / 40.0 + def test_auto_levels_tiny_hot_pixel_ignored(): """ Verify that a very small number of clipped pixels (below eps check) - does NOT trigger pinning, and does NOT get picked up by percentile + does NOT trigger pinning, and does NOT get picked up by percentile if strictly below the threshold. """ editor = ImageEditor() # 200x200 = 40,000 pixels w, h = 200, 200 arr = np.zeros((h, w, 3), dtype=np.uint8) - + # Base: 150 arr[:] = 150 - + # Set top ~2.5% pixels to 200 (1000 pixels) # This ensures the 99.9th percentile lands on 200, not 150. # Flattening for easier assignment flat = arr.reshape(-1, 3) flat[0:1000, :] = 200 arr = flat.reshape(h, w, 3) - + # Add ONE hot pixel at 255 in Red channel arr[0, 0, 0] = 255 - - img = Image.fromarray(arr, 'RGB') + + img = Image.fromarray(arr, "RGB") editor.original_image = img editor._preview_image = img - + # Threshold 0.1%. Eps = 0.01%. # 1 pixel / 40000 = 0.0025%. # 0.0025% < 0.01%. Should NOT pin. - + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) - + # p_high should be 200 (from the 200-level plateau), ignoring the 255. assert p_high == 200.0 - assert p_high != 255.0 # Check explicitly not pinned + assert p_high != 255.0 # Check explicitly not pinned assert whites > 0.0 + def test_auto_levels_degenerate_image(): editor = ImageEditor() w, h = 10, 10 arr = np.zeros((h, w, 3), dtype=np.uint8) arr[:] = 128 - - img = Image.fromarray(arr, 'RGB') + + img = Image.fromarray(arr, "RGB") editor.original_image = img editor._preview_image = img - + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) - + assert p_high == 128.0 assert p_low == 128.0 assert blacks == 0.0 assert whites == 0.0 + def test_auto_levels_normal_range(): editor = ImageEditor() w, h = 10, 10 arr = np.zeros((h, w, 3), dtype=np.uint8) arr[:] = 128 arr[0, 0, :] = 50 # Low - arr[9, 9, :] = 200 # High - - img = Image.fromarray(arr, 'RGB') + arr[9, 9, :] = 200 # High + + img = Image.fromarray(arr, "RGB") editor.original_image = img editor._preview_image = img - + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) - + assert p_high == 200.0 assert p_low == 50.0 - - assert p_high != 255.0 # Not pinned - assert p_low != 0.0 # Not pinned - + + assert p_high != 255.0 # Not pinned + assert p_low != 0.0 # Not pinned + assert whites == (255.0 - 200.0) / 40.0 assert blacks == -50.0 / 40.0 - diff --git a/faststack/tests/test_cache.py b/faststack/tests/test_cache.py index 1a871c0..94c7ee9 100644 --- a/faststack/tests/test_cache.py +++ b/faststack/tests/test_cache.py @@ -1,23 +1,25 @@ """Tests for the byte-aware LRU cache.""" -import pytest - from faststack.imaging.cache import ByteLRUCache + class MockItem: """A mock object with a settable size.""" + def __init__(self, size: int): self._size = size - + def __sizeof__(self) -> int: return self._size + def test_cache_init(): """Tests cache initialization.""" cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) assert cache.max_bytes == 1000 assert cache.currsize == 0 + def test_cache_add_items(): """Tests adding items and tracking size.""" cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) @@ -28,23 +30,25 @@ def test_cache_add_items(): assert "a" in cache assert "b" in cache + def test_cache_eviction(): """Tests that the least recently used item is evicted when full.""" cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x.__sizeof__()) - cache["a"] = MockItem(50) # a is oldest + cache["a"] = MockItem(50) # a is oldest cache["b"] = MockItem(40) - cache["c"] = MockItem(30) # This should evict 'a' + cache["c"] = MockItem(30) # This should evict 'a' assert "a" not in cache assert "b" in cache assert "c" in cache - assert cache.currsize == 70 # 40 + 30 + assert cache.currsize == 70 # 40 + 30 - cache["d"] = MockItem(50) # This should evict 'b' + cache["d"] = MockItem(50) # This should evict 'b' assert "b" not in cache assert "c" in cache assert "d" in cache - assert cache.currsize == 80 # 30 + 50 + assert cache.currsize == 80 # 30 + 50 + def test_cache_update_item(): """Tests that updating an item adjusts the cache size.""" diff --git a/faststack/tests/test_cache_invalidation.py b/faststack/tests/test_cache_invalidation.py index 3f0da9f..2b481cf 100644 --- a/faststack/tests/test_cache_invalidation.py +++ b/faststack/tests/test_cache_invalidation.py @@ -1,4 +1,3 @@ - import sys import os import time @@ -12,54 +11,55 @@ from faststack.imaging.editor import ImageEditor + def test_cache_stability(): """Verify that cache hash remains stable when reloading the same unmodified file.""" - + # Setup dummy image test_dir = Path("tests/dummy_images_cache") test_dir.mkdir(parents=True, exist_ok=True) - + img_path = test_dir / "test_cache.jpg" - + # Create a dummy image arr = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8) Image.fromarray(arr).save(img_path) - + editor = ImageEditor() - + # 1. Load image and get hash editor.load_image(str(img_path)) hash1 = editor._get_upstream_edits_hash(editor.current_edits) - + # 2. Reload same image (simulate switching back and forth) - # Even if we create a new editor or reload, if the file hasn't changed, + # Even if we create a new editor or reload, if the file hasn't changed, # the ideal cache key for *content-dependent* heavy ops should be stable. # However, the current implementation uses id(self.float_image), so we expect this to change # if we reload, because float_image will be a new object. - + editor.load_image(str(img_path)) hash2 = editor._get_upstream_edits_hash(editor.current_edits) - + print(f"Hash 1: {hash1}") print(f"Hash 2: {hash2}") - + # Current behavior: Hashes DIFFERENT because id() changed # Desired behavior: Hashes SAME because content/mtime is same - + if hash1 == hash2: print("PASS: Hash is stable across reloads.") else: print("FAIL: Hash changed across reloads (unnecessary invalidation).") # 3. Touch file to update mtime - time.sleep(1.1) # Ensure mtime changes (some systems have 1s resolution) + time.sleep(1.1) # Ensure mtime changes (some systems have 1s resolution) img_path.touch() - + editor.load_image(str(img_path)) hash3 = editor._get_upstream_edits_hash(editor.current_edits) - + print(f"Hash 3 (after touch): {hash3}") - + if hash3 != hash2: print("PASS: Hash changed after mtime update.") else: @@ -71,5 +71,6 @@ def test_cache_stability(): except: pass + if __name__ == "__main__": test_cache_stability() diff --git a/faststack/tests/test_config_setters.py b/faststack/tests/test_config_setters.py index a3351f0..6bd969f 100644 --- a/faststack/tests/test_config_setters.py +++ b/faststack/tests/test_config_setters.py @@ -1,4 +1,3 @@ - import unittest import sys from unittest.mock import MagicMock, patch @@ -10,38 +9,49 @@ mock_pyside.__path__ = [] mock_pyside.__spec__ = MagicMock() + # Define a real class for QObject so inheritance works as expected class MockQObject: def __init__(self, parent=None): pass - def property(self, name): return None - def setProperty(self, name, value): pass + + def property(self, name): + return None + + def setProperty(self, name, value): + pass + + mock_pyside.QObject = MockQObject + # Mock Slot/Signal decorators to just return the function/dummy def MockSlot(*args, **kwargs): def decorator(func): return func + return decorator + + mock_pyside.Slot = MockSlot mock_pyside.Signal = MagicMock() -sys.modules['PySide6'] = mock_pyside -sys.modules['PySide6.QtCore'] = mock_pyside -sys.modules['PySide6.QtGui'] = mock_pyside -sys.modules['PySide6.QtQuick'] = mock_pyside -sys.modules['PySide6.QtWidgets'] = mock_pyside -sys.modules['PySide6.QtQml'] = mock_pyside +sys.modules["PySide6"] = mock_pyside +sys.modules["PySide6.QtCore"] = mock_pyside +sys.modules["PySide6.QtGui"] = mock_pyside +sys.modules["PySide6.QtQuick"] = mock_pyside +sys.modules["PySide6.QtWidgets"] = mock_pyside +sys.modules["PySide6.QtQml"] = mock_pyside # Mock PIL mock_pil = MagicMock() mock_pil.__path__ = [] mock_pil.Image = MagicMock() -sys.modules['PIL'] = mock_pil -sys.modules['PIL.Image'] = mock_pil.Image +sys.modules["PIL"] = mock_pil +sys.modules["PIL.Image"] = mock_pil.Image # Mock numpy -sys.modules['numpy'] = MagicMock() +sys.modules["numpy"] = MagicMock() # Mock faststack.config mock_config_module = MagicMock() @@ -49,24 +59,25 @@ def decorator(func): mock_config_obj.getfloat.return_value = 0.1 mock_config_obj.getboolean.return_value = False mock_config_module.config = mock_config_obj -sys.modules['faststack.config'] = mock_config_module +sys.modules["faststack.config"] = mock_config_module # Mock faststack modules -sys.modules['faststack.ui.provider'] = MagicMock() -sys.modules['faststack.models'] = MagicMock() -sys.modules['faststack.logging_setup'] = MagicMock() -sys.modules['faststack.io.indexer'] = MagicMock() -sys.modules['faststack.io.sidecar'] = MagicMock() -sys.modules['faststack.io.watcher'] = MagicMock() -sys.modules['faststack.io.helicon'] = MagicMock() -sys.modules['faststack.io.executable_validator'] = MagicMock() -sys.modules['faststack.imaging.cache'] = MagicMock() -sys.modules['faststack.imaging.prefetch'] = MagicMock() -sys.modules['faststack.ui.keystrokes'] = MagicMock() -sys.modules['faststack.imaging.editor'] = MagicMock() -sys.modules['faststack.imaging.metadata'] = MagicMock() +sys.modules["faststack.ui.provider"] = MagicMock() +sys.modules["faststack.models"] = MagicMock() +sys.modules["faststack.logging_setup"] = MagicMock() +sys.modules["faststack.io.indexer"] = MagicMock() +sys.modules["faststack.io.sidecar"] = MagicMock() +sys.modules["faststack.io.watcher"] = MagicMock() +sys.modules["faststack.io.helicon"] = MagicMock() +sys.modules["faststack.io.executable_validator"] = MagicMock() +sys.modules["faststack.imaging.cache"] = MagicMock() +sys.modules["faststack.imaging.prefetch"] = MagicMock() +sys.modules["faststack.ui.keystrokes"] = MagicMock() +sys.modules["faststack.imaging.editor"] = MagicMock() +sys.modules["faststack.imaging.metadata"] = MagicMock() import faststack + print(f"DEBUG: faststack imported from: {faststack.__file__}") print(f"DEBUG: sys.path: {sys.path}") @@ -74,29 +85,32 @@ def decorator(func): from faststack.app import AppController from faststack.config import config + class TestConfigSetters(unittest.TestCase): def setUp(self): # Apply patches using start/addCleanup self.patches = [ - patch('faststack.app.QTimer'), - patch('faststack.app.DecodedImage'), - patch('faststack.app.ImageEditor'), - patch('faststack.app.Prefetcher'), - patch('faststack.app.ByteLRUCache'), - patch('faststack.app.SidecarManager'), - patch('faststack.app.Keybinder'), - patch('faststack.app.Path') + patch("faststack.app.QTimer"), + patch("faststack.app.DecodedImage"), + patch("faststack.app.ImageEditor"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.Keybinder"), + patch("faststack.app.Path"), ] - + for p in self.patches: p.start() self.addCleanup(p.stop) - + # Initialize controller # Mock Path for init argument - mock_path_cls = self.patches[-1].target # access the mock object ? NO, p.start returns mock + mock_path_cls = self.patches[ + -1 + ].target # access the mock object ? NO, p.start returns mock # Ideally capture the return of start() - + # Simpler: just instantiate. The mocks are active. # But we need to pass a mock path to __init__ self.controller = AppController(MagicMock(), MagicMock()) @@ -104,69 +118,70 @@ def setUp(self): def test_set_auto_level_clipping_threshold(self): config.set.reset_mock() config.save.reset_mock() - + # Pre-verify default value (set in __init__ using config.getfloat mock) self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 0.1) - + new_val = 0.5 self.controller.set_auto_level_clipping_threshold(new_val) - + # Verify # Verify normal set self.assertEqual(self.controller.get_auto_level_clipping_threshold(), new_val) # Should be stringified "0.5" - config.set.assert_called_with('core', 'auto_level_threshold', "0.5") + config.set.assert_called_with("core", "auto_level_threshold", "0.5") config.save.assert_called_once() - + # Verify Clamping (High) config.set.reset_mock() self.controller.set_auto_level_clipping_threshold(1.5) self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 1.0) - config.set.assert_called_with('core', 'auto_level_threshold', "1") + config.set.assert_called_with("core", "auto_level_threshold", "1") # Verify Clamping (Low) config.set.reset_mock() self.controller.set_auto_level_clipping_threshold(-0.1) self.assertEqual(self.controller.get_auto_level_clipping_threshold(), 0.0) - config.set.assert_called_with('core', 'auto_level_threshold', "0") + config.set.assert_called_with("core", "auto_level_threshold", "0") def test_set_auto_level_strength(self): config.set.reset_mock() config.save.reset_mock() - + # Default was 1.0 in code, but our mock config.getfloat returns 0.1 # AppController: self.auto_level_strength = config.getfloat(..., 1.0) # Mock config.getfloat returns 0.1 always as setup above. - + new_val = 0.8 self.controller.set_auto_level_strength(new_val) - + self.assertEqual(self.controller.get_auto_level_strength(), new_val) - config.set.assert_called_with('core', 'auto_level_strength', "0.8") + config.set.assert_called_with("core", "auto_level_strength", "0.8") config.save.assert_called_once() - + # Verify Clamping config.set.reset_mock() self.controller.set_auto_level_strength(2.0) self.assertEqual(self.controller.get_auto_level_strength(), 1.0) - config.set.assert_called_with('core', 'auto_level_strength', "1") + config.set.assert_called_with("core", "auto_level_strength", "1") def test_set_auto_level_strength_auto(self): config.set.reset_mock() config.save.reset_mock() - + new_val = True self.controller.set_auto_level_strength_auto(new_val) - + self.assertEqual(self.controller.get_auto_level_strength_auto(), new_val) # Should be normalized "true" string - config.set.assert_called_with('core', 'auto_level_strength_auto', "true") + config.set.assert_called_with("core", "auto_level_strength_auto", "true") config.save.assert_called_once() - + # Test False config.set.reset_mock() self.controller.set_auto_level_strength_auto(False) - config.set.assert_called_with('core', 'auto_level_strength_auto', "false") + config.set.assert_called_with("core", "auto_level_strength_auto", "false") + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_developed_sorting.py b/faststack/tests/test_developed_sorting.py index 0271126..8d03a27 100644 --- a/faststack/tests/test_developed_sorting.py +++ b/faststack/tests/test_developed_sorting.py @@ -1,9 +1,7 @@ - import os -import shutil -from pathlib import Path from faststack.io.indexer import find_images + def test_developed_sorting_adjacency(tmp_path): """ Test that developed images appear immediately after their base images, @@ -13,29 +11,30 @@ def test_developed_sorting_adjacency(tmp_path): # A.jpg (old) # B.jpg (mid) # A-developed.jpg (new) - + a_path = tmp_path / "A.jpg" b_path = tmp_path / "B.jpg" a_dev_path = tmp_path / "A-developed.jpg" - + a_path.touch() os.utime(a_path, (1000, 1000)) - + b_path.touch() os.utime(b_path, (2000, 2000)) - + a_dev_path.touch() os.utime(a_dev_path, (3000, 3000)) - + images = find_images(tmp_path) - + # Expected order: A.jpg, A-developed.jpg, B.jpg # Because A-developed matches A, and A is older than B. # Without the fix, A-developed (3000) would be after B (2000). - + names = [im.path.name for im in images] assert names == ["A.jpg", "A-developed.jpg", "B.jpg"] + def test_developed_orphan_sorting(tmp_path): """ Test that a developed image without a base image is sorted by its own mtime. @@ -43,40 +42,42 @@ def test_developed_orphan_sorting(tmp_path): # A.jpg (1000) # B-developed.jpg (2000) - orphan # C.jpg (3000) - + (tmp_path / "A.jpg").touch() os.utime(tmp_path / "A.jpg", (1000, 1000)) - + (tmp_path / "B-developed.jpg").touch() os.utime(tmp_path / "B-developed.jpg", (2000, 2000)) - + (tmp_path / "C.jpg").touch() os.utime(tmp_path / "C.jpg", (3000, 3000)) - + images = find_images(tmp_path) names = [im.path.name for im in images] assert names == ["A.jpg", "B-developed.jpg", "C.jpg"] + def test_base_resolution_preference(tmp_path): """ Test that A-developed.jpg prefers A.jpg over A (1).jpg. """ (tmp_path / "A.jpg").touch() os.utime(tmp_path / "A.jpg", (1000, 1000)) - + (tmp_path / "A (1).jpg").touch() os.utime(tmp_path / "A (1).jpg", (1100, 1100)) - + (tmp_path / "A-developed.jpg").touch() os.utime(tmp_path / "A-developed.jpg", (3000, 3000)) - + images = find_images(tmp_path) names = [im.path.name for im in images] - + # A-developed should match A.jpg and stay at 1000 (after A.jpg) # Order: A.jpg (1000), A-developed.jpg (1000 rank 1), A (1).jpg (1100) assert names == ["A.jpg", "A-developed.jpg", "A (1).jpg"] + def test_raw_pairing_with_developed(tmp_path): """ Test that A.orf pairs with A.jpg, not A-developed.jpg. @@ -85,88 +86,91 @@ def test_raw_pairing_with_developed(tmp_path): a_jpg = tmp_path / "A.jpg" a_orf = tmp_path / "A.orf" a_dev = tmp_path / "A-developed.jpg" - + a_jpg.touch() os.utime(a_jpg, (1000, 1000)) - + a_orf.touch() os.utime(a_orf, (1000, 1000)) - + a_dev.touch() os.utime(a_dev, (3000, 3000)) - + images = find_images(tmp_path) - + # Should have 2 images in list: # 1. A.jpg (paired with A.orf) # 2. A-developed.jpg (no pair) - + assert len(images) == 2 - + # Check pairing img_a = next(im for im in images if im.path.name == "A.jpg") img_dev = next(im for im in images if im.path.name == "A-developed.jpg") - + assert img_a.raw_pair is not None assert img_a.raw_pair.name == "A.orf" assert img_dev.raw_pair is None - + # Check ordering names = [im.path.name for im in images] assert names == ["A.jpg", "A-developed.jpg"] + def test_case_insensitivity(tmp_path): """Test that a-DEVELOPED.JPG matches A.jpg.""" (tmp_path / "A.jpg").touch() os.utime(tmp_path / "A.jpg", (1000, 1000)) - + (tmp_path / "a-DEVELOPED.JPG").touch() os.utime(tmp_path / "a-DEVELOPED.JPG", (3000, 3000)) - + images = find_images(tmp_path) names = [im.path.name for im in images] # Note: casefold sorting might affect order if original names differ only in case, # but here they are grouped by A.jpg's time. assert names == ["A.jpg", "a-DEVELOPED.JPG"] + def test_orphan_chain_prevention(tmp_path): """ - A-developed (1).jpg should be treated as an orphan, + A-developed (1).jpg should be treated as an orphan, not matched to A-developed.jpg or A.jpg accidentally. """ (tmp_path / "A.jpg").touch() os.utime(tmp_path / "A.jpg", (1000, 1000)) - + (tmp_path / "A-developed.jpg").touch() os.utime(tmp_path / "A-developed.jpg", (1100, 1100)) - - # This one has -developed (1) suffix. + + # This one has -developed (1) suffix. # Our simple logic should either not match it or match it to A (1).jpg if it existed. # Without A (1).jpg, it should be an orphan. (tmp_path / "A-developed (1).jpg").touch() os.utime(tmp_path / "A-developed (1).jpg", (1200, 1200)) - + images = find_images(tmp_path) names = [im.path.name for im in images] assert names == ["A.jpg", "A-developed.jpg", "A-developed (1).jpg"] + def test_tiebreaker_stability(tmp_path): """ - Test that the tiebreaker (last element of the sorting key) + Test that the tiebreaker (last element of the sorting key) provides stable ordering when mtime and casefolded names are identical. """ p1 = tmp_path / "100.jpg" p2 = tmp_path / "200.jpg" - + p1.touch() os.utime(p1, (1000, 1000)) - + p2.touch() os.utime(p2, (1000, 1000)) - + images = find_images(tmp_path) names = [im.path.name for im in images] - + # Both have same mtime (1000) and priority 0. # Tiebreakers are now name-based, so "100.jpg" comes before "200.jpg". assert names == ["100.jpg", "200.jpg"] diff --git a/faststack/tests/test_drag_logic.py b/faststack/tests/test_drag_logic.py index 18d396d..64d4518 100644 --- a/faststack/tests/test_drag_logic.py +++ b/faststack/tests/test_drag_logic.py @@ -1,71 +1,74 @@ - -import pytest -from pathlib import Path -from unittest.mock import MagicMock, patch from faststack.models import ImageFile # We can't easily instantiate AppController without complex mocks for QML engine, etc. # So we test the logic extracted from start_drag_current_image. -def get_drag_paths(image_files, current_index, existing_indices, current_edit_source_mode): + +def get_drag_paths( + image_files, current_index, existing_indices, current_edit_source_mode +): file_paths = [] for idx in existing_indices: img = image_files[idx] - + # logic from app.py is_developed_artifact = img.path.stem.lower().endswith("-developed") - in_raw_mode = (current_edit_source_mode == "raw") - + in_raw_mode = current_edit_source_mode == "raw" + if (in_raw_mode or is_developed_artifact) and img.developed_jpg_path.exists(): file_paths.append(img.developed_jpg_path) else: file_paths.append(img.path) return file_paths + def test_drag_logic_jpeg_mode(tmp_path): """In JPEG mode, prefer the original JPG even if -developed exists.""" jpg_path = tmp_path / "A.jpg" dev_path = tmp_path / "A-developed.jpg" jpg_path.touch() dev_path.touch() - + img = ImageFile(path=jpg_path) # Note: developed_jpg_path is a property that calculates the path - + paths = get_drag_paths([img], 0, [0], "jpeg") assert paths == [jpg_path] + def test_drag_logic_raw_mode(tmp_path): """In RAW mode, prefer -developed.jpg if it exists.""" jpg_path = tmp_path / "A.jpg" dev_path = tmp_path / "A-developed.jpg" jpg_path.touch() dev_path.touch() - + img = ImageFile(path=jpg_path) - + paths = get_drag_paths([img], 0, [0], "raw") assert paths == [dev_path] + def test_drag_logic_developed_artifact(tmp_path): """If the dragged file IS a developed artifact, it should prefer -developed.jpg (itself).""" # This case might be rare if the indexer handles it, but let's test the logic. dev_path = tmp_path / "A-developed.jpg" dev_path.touch() - - # In this case, developed_jpg_path will be "A-developed-developed.jpg" + + # In this case, developed_jpg_path will be "A-developed-developed.jpg" # which won't exist. So it should fallback to itself. img = ImageFile(path=dev_path) - + paths = get_drag_paths([img], 0, [0], "jpeg") assert paths == [dev_path] + def test_drag_logic_raw_mode_missing_developed(tmp_path): """In RAW mode, if -developed.jpg is missing, fallback to main path.""" jpg_path = tmp_path / "A.jpg" jpg_path.touch() - + img = ImageFile(path=jpg_path) - + paths = get_drag_paths([img], 0, [0], "raw") assert paths == [jpg_path] diff --git a/faststack/tests/test_editor.py b/faststack/tests/test_editor.py index 944c68e..da812e3 100644 --- a/faststack/tests/test_editor.py +++ b/faststack/tests/test_editor.py @@ -1,6 +1,7 @@ import os import unittest from PIL import Image + try: from pytest import approx except ImportError: @@ -9,26 +10,29 @@ def approx(val, rel=None, abs=None): class Approx: def __init__(self, expected): self.expected = expected + def __eq__(self, other): return abs_val(self.expected - other) <= (abs or 1e-6) + return Approx(val) - + def abs_val(x): return x if x >= 0 else -x + from faststack.imaging.editor import ImageEditor -class TestEditor(unittest.TestCase): +class TestEditor(unittest.TestCase): def test_save_image_preserves_mtime(self): import tempfile from pathlib import Path import shutil - + tmp_dir = tempfile.mkdtemp() try: tmp_path = Path(tmp_dir) - + img_path = tmp_path / "sample.jpg" Image.new("RGB", (4, 4), color=(10, 20, 30)).save(img_path) @@ -37,7 +41,7 @@ def test_save_image_preserves_mtime(self): editor = ImageEditor() self.assertTrue(editor.load_image(str(img_path))) - editor.set_edit_param('brightness', 0.1) + editor.set_edit_param("brightness", 0.1) saved = editor.save_image() self.assertIsNotNone(saved) @@ -57,16 +61,17 @@ def test_texture_edit(self): import tempfile from pathlib import Path import shutil + try: import cv2 except ImportError: cv2 = None - + if cv2 is None: self.skipTest("OpenCV not installed, skipping texture test") import numpy as np - + tmp_dir = tempfile.mkdtemp() try: tmp_path = Path(tmp_dir) @@ -75,21 +80,21 @@ def test_texture_edit(self): arr = np.zeros((20, 20, 3), dtype=np.uint8) arr[::2, ::2] = 255 Image.fromarray(arr).save(img_path) - + self.assertTrue(editor.load_image(str(img_path))) - + # Baseline orig_arr = editor.float_image.copy() preview_orig = editor._apply_edits(orig_arr.copy()) - + # Apply Texture - editor.set_edit_param('texture', 0.5) + editor.set_edit_param("texture", 0.5) preview_tex = editor._apply_edits(orig_arr.copy()) - + # Should be different # Depending on how texture works, mean might shift slightly or just variance. # But the arrays should not be identical. self.assertFalse(np.allclose(preview_orig, preview_tex)) - + finally: shutil.rmtree(tmp_dir) diff --git a/faststack/tests/test_editor_error_handling.py b/faststack/tests/test_editor_error_handling.py index 8792f73..49c6e1d 100644 --- a/faststack/tests/test_editor_error_handling.py +++ b/faststack/tests/test_editor_error_handling.py @@ -1,15 +1,13 @@ - import sys import unittest -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock, patch import numpy as np from pathlib import Path -import tempfile -import os # We need to mock cv2 before importing editor if it's not already imported, # but since tests run in the same process, we just rely on patching. + class TestEditorErrorHandling(unittest.TestCase): """Test ImageEditor error handling for load and save operations.""" @@ -20,17 +18,17 @@ def test_load_image_returns_false_on_failure(self): editor = ImageEditor() # Patch Image.open to raise an exception - with patch('PIL.Image.open', side_effect=OSError("Mocked file error")): - # We also need to ensure cv2 doesn't rescue it. - # If cv2 exists, it might try to load. - # Let's mock cv2.imread to return None so it falls back to PIL, which fails. + with patch("PIL.Image.open", side_effect=OSError("Mocked file error")): + # We also need to ensure cv2 doesn't rescue it. + # If cv2 exists, it might try to load. + # Let's mock cv2.imread to return None so it falls back to PIL, which fails. - with patch.dict(sys.modules, {'cv2': MagicMock()}): - sys.modules['cv2'].imread.return_value = None + with patch.dict(sys.modules, {"cv2": MagicMock()}): + sys.modules["cv2"].imread.return_value = None - # load_image returns False on failure, not raises - result = editor.load_image("non_existent_file.jpg") - self.assertFalse(result) + # load_image returns False on failure, not raises + result = editor.load_image("non_existent_file.jpg") + self.assertFalse(result) def test_save_image_raises_runtime_error_on_failure(self): """Ensure save_image raises RuntimeError when saving fails.""" @@ -43,17 +41,21 @@ def test_save_image_raises_runtime_error_on_failure(self): editor.current_filepath = Path("fake_path.jpg") # Patch create_backup_file to succeed - with patch('faststack.imaging.editor.create_backup_file', return_value=Path("backup.jpg")): - # Patch Image.fromarray to return a mock that fails to save - mock_img = MagicMock() - mock_img.save.side_effect = PermissionError("Mocked save error") + with patch( + "faststack.imaging.editor.create_backup_file", + return_value=Path("backup.jpg"), + ): + # Patch Image.fromarray to return a mock that fails to save + mock_img = MagicMock() + mock_img.save.side_effect = PermissionError("Mocked save error") + + with patch("PIL.Image.fromarray", return_value=mock_img): + # save_image wraps exceptions in RuntimeError + with self.assertRaises(RuntimeError) as cm: + editor.save_image() - with patch('PIL.Image.fromarray', return_value=mock_img): - # save_image wraps exceptions in RuntimeError - with self.assertRaises(RuntimeError) as cm: - editor.save_image() + self.assertIn("Mocked save error", str(cm.exception)) - self.assertIn("Mocked save error", str(cm.exception)) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_editor_integration.py b/faststack/tests/test_editor_integration.py index 51b3e22..9dc4525 100644 --- a/faststack/tests/test_editor_integration.py +++ b/faststack/tests/test_editor_integration.py @@ -1,4 +1,3 @@ - import unittest from unittest.mock import MagicMock, patch from pathlib import Path @@ -9,24 +8,27 @@ from faststack.app import AppController + class TestEditorIntegration(unittest.TestCase): def setUp(self): # Mock dependencies for AppController self.mock_engine = MagicMock() self.mock_config = MagicMock() - + # Patch config to avoid file I/O or errors - self.config_patcher = patch('faststack.app.config') + self.config_patcher = patch("faststack.app.config") self.mock_config_module = self.config_patcher.start() - + # Instantiate AppController with a dummy path # We need to mock Watcher and SidecarManager because they start threads/file IO - with patch('faststack.app.Watcher'), \ - patch('faststack.app.SidecarManager'), \ - patch('faststack.app.Prefetcher'), \ - patch('faststack.app.ByteLRUCache'): + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ByteLRUCache"), + ): self.controller = AppController(Path("."), self.mock_engine) - + # Mock the internal image_editor to verify delegation self.controller.image_editor = MagicMock() self.controller.image_editor.current_filepath = Path("test.jpg") @@ -38,12 +40,14 @@ def tearDown(self): def test_missing_methods(self): """Verify that the methods expected by QML exist and delegate to ImageEditor.""" - + # 1. set_edit_parameter # Try calling the method. If it doesn't exist, this will raise AttributeError try: self.controller.set_edit_parameter("exposure", 0.5) - self.controller.image_editor.set_edit_param.assert_called_with("exposure", 0.5) + self.controller.image_editor.set_edit_param.assert_called_with( + "exposure", 0.5 + ) except AttributeError: self.fail("AppController is missing method 'set_edit_parameter'") @@ -60,7 +64,7 @@ def test_missing_methods(self): self.controller.image_editor.rotate_image_ccw.assert_called_once() except AttributeError: self.fail("AppController is missing method 'rotate_image_ccw'") - + # 4. reset_edit_parameters try: self.controller.reset_edit_parameters() @@ -74,7 +78,7 @@ def test_missing_methods(self): self.controller.image_editor.save_image.assert_called_once() except AttributeError: self.fail("AppController is missing method 'save_edited_image'") - + # 6. auto_levels try: self.controller.auto_levels() @@ -84,38 +88,38 @@ def test_missing_methods(self): # 7. update_histogram # This one might be complex to mock fully due to threading, but we check existence - if not hasattr(self.controller, 'update_histogram'): + if not hasattr(self.controller, "update_histogram"): self.fail("AppController is missing method 'update_histogram'") - def test_set_edit_parameter_gating(self): """Regression test for proper gating of set_edit_parameter.""" - + # Setup mocks self.controller.image_editor = MagicMock() - + # Case 1: Editor closed (ui_state flag False), but image LOADED. # Should allow edits (robustness fix). self.controller.ui_state.isEditorOpen = False self.controller.image_editor.current_filepath = Path("test.jpg") - self.controller.image_editor.original_image = MagicMock() # Has image + self.controller.image_editor.original_image = MagicMock() # Has image self.controller.image_editor.float_image = None - + self.controller.set_edit_parameter("exposure", 0.5) self.controller.image_editor.set_edit_param.assert_called_with("exposure", 0.5) - + # Reset mocks self.controller.image_editor.reset_mock() - + # Case 2: Editor OPEN (flag True), but NO image loaded. # Should BLOCK edits (safety fix). self.controller.ui_state.isEditorOpen = True self.controller.image_editor.current_filepath = None self.controller.image_editor.original_image = None self.controller.image_editor.float_image = None - + self.controller.set_edit_parameter("exposure", 0.8) self.controller.image_editor.set_edit_param.assert_not_called() -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_editor_lifecycle_and_safety.py b/faststack/tests/test_editor_lifecycle_and_safety.py index 17d60ab..7127621 100644 --- a/faststack/tests/test_editor_lifecycle_and_safety.py +++ b/faststack/tests/test_editor_lifecycle_and_safety.py @@ -1,49 +1,47 @@ - import unittest from unittest.mock import MagicMock, patch from pathlib import Path import sys -import threading -import time # Ensure we can import faststack sys.path.append(str(Path(__file__).parents[2])) from faststack.app import AppController + class TestEditorLifecycleAndSafety(unittest.TestCase): def setUp(self): # Mock dependencies for AppController self.mock_engine = MagicMock() - + # Patch dependencies that do I/O or threading - self.watcher_patcher = patch('faststack.app.Watcher') - self.sidecar_patcher = patch('faststack.app.SidecarManager') - self.prefetcher_patcher = patch('faststack.app.Prefetcher') - self.cache_patcher = patch('faststack.app.ByteLRUCache') - self.config_patcher = patch('faststack.app.config') - + self.watcher_patcher = patch("faststack.app.Watcher") + self.sidecar_patcher = patch("faststack.app.SidecarManager") + self.prefetcher_patcher = patch("faststack.app.Prefetcher") + self.cache_patcher = patch("faststack.app.ByteLRUCache") + self.config_patcher = patch("faststack.app.config") + self.mock_watcher = self.watcher_patcher.start() self.mock_sidecar = self.sidecar_patcher.start() self.mock_prefetcher = self.prefetcher_patcher.start() self.mock_cache = self.cache_patcher.start() self.mock_config = self.config_patcher.start() - + # Default config values to allow init self.mock_config.getfloat.return_value = 1.0 self.mock_config.getboolean.return_value = False self.mock_config.getint.return_value = 4 - + # Mock QCoreApplication.instance() to prevent RuntimeError - self.qapp_patcher = patch('faststack.app.QCoreApplication') + self.qapp_patcher = patch("faststack.app.QCoreApplication") self.mock_qapp = self.qapp_patcher.start() self.mock_qapp.instance.return_value.aboutToQuit.connect = MagicMock() - + # Instantiate AppController - with patch('faststack.app.ImageEditor') as mock_editor_cls: + with patch("faststack.app.ImageEditor") as mock_editor_cls: self.controller = AppController(Path("."), self.mock_engine) self.mock_editor_instance = self.controller.image_editor - + def tearDown(self): self.watcher_patcher.stop() self.sidecar_patcher.stop() @@ -51,49 +49,54 @@ def tearDown(self): self.cache_patcher.stop() self.config_patcher.stop() self.qapp_patcher.stop() - + # Ensure we shutdown executors to avoid hanging tests self.controller._shutdown_executors() def test_memory_cleanup_on_editor_close(self): """Verify that memory is cleared when the editor is closed.""" - + # 1. Simulate opening the editor # (Technically we just care about the transition to closed, but good to be thorough) self.controller._on_editor_open_changed(True) self.mock_editor_instance.clear.assert_not_called() - + # 2. Simulate closing the editor # The signal connection is already tested by Qt usually, we test the handler logic here self.controller._on_editor_open_changed(False) - + # 3. Verify clear() was called on the editor self.mock_editor_instance.clear.assert_called_once() - + # 4. Verify preview cache was cleared with self.controller._preview_lock: self.assertIsNone(self.controller._last_rendered_preview) def test_histogram_worker_submission_safety(self): """Verify that histogram inflight flag is reset if submission fails.""" - + # Setup: Pending histogram update - self.controller._hist_pending = (1.0, 0, 0, 1.0) # args + self.controller._hist_pending = (1.0, 0, 0, 1.0) # args self.controller._hist_inflight = False - + # Mock executor to raise an exception on submit - self.controller._hist_executor.submit = MagicMock(side_effect=TypeError("Simulated submission failure")) - + self.controller._hist_executor.submit = MagicMock( + side_effect=TypeError("Simulated submission failure") + ) + # Mock preview preview data to ensure we try to submit self.controller._last_rendered_preview = MagicMock() - + # Execute self.controller._kick_histogram_worker() - + # Verify: # 1. Flag should be FALSE (reset after error) - self.assertFalse(self.controller._hist_inflight, "Histogram inflight flag should be reset after submission error") - + self.assertFalse( + self.controller._hist_inflight, + "Histogram inflight flag should be reset after submission error", + ) + # 2. _hist_pending was consumed (set to None inside the method before submitting) # Wait, usually if it fails, we might want to retry? # The current implementation just logs error and clears inflight. @@ -101,5 +104,6 @@ def test_histogram_worker_submission_safety(self): # This is acceptable behavior: drop the failed frame, wait for next update. self.assertIsNone(self.controller._hist_pending) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_editor_loading.py b/faststack/tests/test_editor_loading.py index 37b995d..56c8755 100644 --- a/faststack/tests/test_editor_loading.py +++ b/faststack/tests/test_editor_loading.py @@ -5,11 +5,11 @@ Note: cv2 is imported INSIDE the load_image() function, so we need to patch sys.modules['cv2'] before the import happens. """ + import sys import unittest from unittest.mock import MagicMock, patch import numpy as np -from pathlib import Path import tempfile import os @@ -29,12 +29,13 @@ def tearDown(self): except (OSError, PermissionError): pass - def _create_temp_image(self, color='red'): + def _create_temp_image(self, color="red"): """Create a temporary image file and return its path.""" from PIL import Image - fd, temp_path = tempfile.mkstemp(suffix='.jpg') + + fd, temp_path = tempfile.mkstemp(suffix=".jpg") os.close(fd) # Close the file descriptor so PIL can write to it - img = Image.new('RGB', (10, 10), color=color) + img = Image.new("RGB", (10, 10), color=color) img.save(temp_path) self.temp_files.append(temp_path) return temp_path @@ -45,44 +46,43 @@ def _run_with_mocked_cv2(self, imread_return_value, temp_path): mock_cv2 = MagicMock() mock_cv2.imread.return_value = imread_return_value mock_cv2.IMREAD_UNCHANGED = -1 - + # Patch cv2 in sys.modules before importing editor - with patch.dict(sys.modules, {'cv2': mock_cv2}): + with patch.dict(sys.modules, {"cv2": mock_cv2}): # Force reimport of editor to pick up the mocked cv2 - if 'faststack.imaging.editor' in sys.modules: - del sys.modules['faststack.imaging.editor'] + if "faststack.imaging.editor" in sys.modules: + del sys.modules["faststack.imaging.editor"] from faststack.imaging.editor import ImageEditor - + editor = ImageEditor() result = editor.load_image(temp_path) return editor, result def test_imread_returns_none(self): """cv2.imread returning None should fall back to PIL.""" - temp_path = self._create_temp_image('red') + temp_path = self._create_temp_image("red") editor, result = self._run_with_mocked_cv2(None, temp_path) - + self.assertTrue(result, "load_image should succeed with PIL fallback") self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") self.assertIsNotNone(editor.float_image, "float_image should be set") def test_imread_returns_empty_array(self): """cv2.imread returning an empty array should fall back to PIL.""" - temp_path = self._create_temp_image('blue') + temp_path = self._create_temp_image("blue") editor, result = self._run_with_mocked_cv2(np.array([]), temp_path) - + self.assertTrue(result, "load_image should succeed with PIL fallback") self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") def test_imread_returns_non_array(self): """cv2.imread returning a non-array object should fall back to PIL.""" - temp_path = self._create_temp_image('green') + temp_path = self._create_temp_image("green") editor, result = self._run_with_mocked_cv2("not an array", temp_path) - + self.assertTrue(result, "load_image should succeed with PIL fallback") self.assertEqual(editor.bit_depth, 8, "Should fall back to 8-bit") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() - diff --git a/faststack/tests/test_editor_rotation.py b/faststack/tests/test_editor_rotation.py index d1abe5f..6994d1a 100644 --- a/faststack/tests/test_editor_rotation.py +++ b/faststack/tests/test_editor_rotation.py @@ -1,9 +1,12 @@ - import pytest import math from PIL import Image -import numpy as np -from faststack.imaging.editor import _rotated_rect_with_max_area, rotate_autocrop_rgb, ImageEditor +from faststack.imaging.editor import ( + _rotated_rect_with_max_area, + rotate_autocrop_rgb, + ImageEditor, +) + def test_rotated_rect_edge_cases(): """Test fundamental edge cases for the rectangle calculation.""" @@ -26,36 +29,40 @@ def test_rotated_rect_edge_cases(): # So 90 deg becomes 0 deg effectively for rect calculation purposes in this specific helper # because a 90 deg rotated rect inscribed in a 90 deg rotated image is the same rect. # Let's test 89.9 degrees converted to radians - angle_rad = math.radians(89.9) + angle_rad = math.radians(89.9) # Logic in function: if angle > pi/4, it subtracts from pi/2. # So 89.9 becomes 0.1 deg. cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) - # Should be very close to swapping w and h if we were inscribing, but wait - + # Should be very close to swapping w and h if we were inscribing, but wait - # The function finds largest axis-aligned rect *within* the rotated w x h. - # If we rotate 100x50 by 90deg, we have a 50x100 bounding box. + # If we rotate 100x50 by 90deg, we have a 50x100 bounding box. # The largest axis aligned rect in a 50x100 box is 50x100. - # But let's stick to the simpler assertion: it returns something valid [1, w] x [1, h] - # (The function clamps to original w/h, which might be a bit counter-intuitive for 90deg + # But let's stick to the simpler assertion: it returns something valid [1, w] x [1, h] + # (The function clamps to original w/h, which might be a bit counter-intuitive for 90deg # if we wanted the swapped dims, but for small-angle straightening it's fine). assert 1 <= cw <= w assert 1 <= ch <= h -@pytest.mark.parametrize("w,h,angle_deg", [ - (100, 100, 0), # Unrotated - (200, 100, 45), # Diagonal Square (Fully constrained case often) - (1000, 500, 15), # Half constrained case likely - (500, 1000, 15), # Tall half constrained -]) + +@pytest.mark.parametrize( + "w,h,angle_deg", + [ + (100, 100, 0), # Unrotated + (200, 100, 45), # Diagonal Square (Fully constrained case often) + (1000, 500, 15), # Half constrained case likely + (500, 1000, 15), # Tall half constrained + ], +) def test_rotated_rect_calculation_branches(w, h, angle_deg): """Exercise different geometric branches of the calculation.""" angle_rad = math.radians(angle_deg) cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) - + assert cw > 0 assert ch > 0 assert cw <= w assert ch <= h - + if angle_deg == 0: assert cw == w assert ch == h @@ -63,134 +70,137 @@ def test_rotated_rect_calculation_branches(w, h, angle_deg): # Non-zero rotation always reduces the inscribed axis-aligned box assert cw * ch < w * h + def test_rotate_autocrop_rgb_behavior(): """Test actual image formatting and cropping.""" # Create valid RGB image w, h = 100, 100 - img = Image.new("RGB", (w, h), color=(255, 0, 0)) # Red - + img = Image.new("RGB", (w, h), color=(255, 0, 0)) # Red + # 1. Test no rotation res = rotate_autocrop_rgb(img, 0.0) assert res.size == (100, 100) - + # 2. Test rotation with inset angle = 45.0 inset = 2 res = rotate_autocrop_rgb(img, angle, inset=inset) - + # At 45 deg, a square becomes a diamond. The max inscribed rect is w/(sqrt(2)) ~ 0.707*w - # 100 * 0.707 = 70. + # 100 * 0.707 = 70. # We expect roughly 70x70 minus inset. # expected_approx = 70.0 assert 60 < res.width < 80 assert 60 < res.height < 80 - + # Verify no black wedges (since original was all red) # Center pixel should definitely be red cx, cy = res.width // 2, res.height // 2 assert res.getpixel((cx, cy)) == (255, 0, 0) - + # Corner pixels should also be red if cropped correctly assert res.getpixel((0, 0)) == (255, 0, 0) - assert res.getpixel((res.width-1, res.height-1)) == (255, 0, 0) + assert res.getpixel((res.width - 1, res.height - 1)) == (255, 0, 0) def test_boundary_clamping(): """Test internal clamping logic.""" img = Image.new("RGB", (10, 10), (255, 255, 255)) - + # Very small image, 45 deg rotation # Inscribed rect will be small. # high inset could theoretically reduce it to < 0. - res = rotate_autocrop_rgb(img, 45, inset=50) # Huge inset - + res = rotate_autocrop_rgb(img, 45, inset=50) # Huge inset + # It should clamp to at least 1x1 or similar valid image, not crash assert res.width > 0 assert res.height > 0 - + + def test_integration_straighten_modes(): """ Integration test comparing Scenario A (Manual Crop) vs Scenario B (Straighten Only). - + Scenario A: User rotates + manually crops. The rotation expands canvas, user picks crop. Scenario B: User rotates only. We autocrop to remove wedges. """ # Create image with specific pattern to verify content w, h = 200, 100 - img = Image.new("RGB", (w, h), (0, 255, 0)) # Green - + img = Image.new("RGB", (w, h), (0, 255, 0)) # Green + editor = ImageEditor() editor.original_image = img - editor.current_filepath = "dummy.jpg" # Needed for save, but not here - + editor.current_filepath = "dummy.jpg" # Needed for save, but not here + angle = 10.0 - + # --- Scenario B: Straighten Only --- - editor.current_edits['straighten_angle'] = angle - editor.current_edits['crop_box'] = None - + editor.current_edits["straighten_angle"] = angle + editor.current_edits["crop_box"] = None + res_b = editor._apply_edits(img.copy(), for_export=True) - + # Should define a specific size based on autocrop w_b, h_b = res_b.size - + # --- Scenario A: Manual Crop --- # We want to simulate the logic where we replicate what autocrop would have done, # but manually via crop_box. # 1. Calculate what the autocrop rect would be relative to the *rotated* canvas. - # Note: _rotated_rect yields dims in *original* pixel space generally, + # Note: _rotated_rect yields dims in *original* pixel space generally, # but let's look at how app.py handles normalization or how editor applies it. - - # Actually, let's just assert that if we manually crop to the SAME pixels + + # Actually, let's just assert that if we manually crop to the SAME pixels # that autocrop found, we get the same result. - + # Re-use the helper to find the crop box angle_rad = math.radians(angle) cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) - + # rotate_autocrop_rgb logic: # It rotates with expand=True. The new center is center of rotated image. # It crops centered rect of size (cw, ch). - + # So if we emulate this in editor: - editor.current_edits['straighten_angle'] = angle - - # We need to compute the 'crop_box' (normalized 0-1000) that corresponds + editor.current_edits["straighten_angle"] = angle + + # We need to compute the 'crop_box' (normalized 0-1000) that corresponds # to that center crop on the ROTATED image. - + # Get rotated size rot_temp = img.rotate(-angle, expand=True) rw, rh = rot_temp.size - + cx, cy = rw / 2.0, rh / 2.0 left = cx - cw / 2.0 top = cy - ch / 2.0 right = left + cw bottom = top + ch - + # Normalize to 0-1000 relative to rotated size # (Editor applies crop_box relative to the current (rotated) image size) n_left = int(left / rw * 1000) n_top = int(top / rh * 1000) n_right = int(right / rw * 1000) n_bottom = int(bottom / rh * 1000) - - editor.current_edits['crop_box'] = (n_left, n_top, n_right, n_bottom) - + + editor.current_edits["crop_box"] = (n_left, n_top, n_right, n_bottom) + res_a = editor._apply_edits(img.copy(), for_export=True) - + # Allow for 1-2 pixel differences due to int/round conversions in normalization assert abs(res_a.width - w_b) < 5 assert abs(res_a.height - h_b) < 5 - + # Verify both are Green (center pixel) - assert res_a.getpixel((res_a.width//2, res_a.height//2)) == (0, 255, 0) + assert res_a.getpixel((res_a.width // 2, res_a.height // 2)) == (0, 255, 0) # ------------------------------------------------------------------------- # Regression Tests for Rotation Direction (CW/CCW) # ------------------------------------------------------------------------- + def create_quadrant_image(w=100, h=100): """ Creates an image with 4 distinct colored quadrants. @@ -201,21 +211,22 @@ def create_quadrant_image(w=100, h=100): """ img = Image.new("RGB", (w, h)) pixels = img.load() - + cx, cy = w // 2, h // 2 - + for y in range(h): for x in range(w): if x < cx and y < cy: - pixels[x, y] = (255, 0, 0) # TL Red + pixels[x, y] = (255, 0, 0) # TL Red elif x >= cx and y < cy: - pixels[x, y] = (0, 255, 0) # TR Green + pixels[x, y] = (0, 255, 0) # TR Green elif x < cx and y >= cy: - pixels[x, y] = (0, 0, 255) # BL Blue + pixels[x, y] = (0, 0, 255) # BL Blue else: - pixels[x, y] = (255, 255, 255) # BR White + pixels[x, y] = (255, 255, 255) # BR White return img + def test_rotate_cw(): """Test that rotate_cw rotates 90 degrees Clockwise.""" editor = ImageEditor() @@ -223,36 +234,36 @@ def test_rotate_cw(): editor.current_filepath = "dummy.jpg" # Initial state: 0 rotation - assert editor.current_edits['rotation'] == 0 + assert editor.current_edits["rotation"] == 0 # Rotate CW (Logic in app.py subtracts 90, so local state becomes 270) # editor.rotate_image_cw() implementation: (current - 90) % 360 editor.rotate_image_cw() - - assert editor.current_edits['rotation'] == 270 - + + assert editor.current_edits["rotation"] == 270 + # Apply edits # PIL Transpose constants: # ROTATE_90: 90 CCW (Left) # ROTATE_270: 270 CCW (Right/CW) # Expected for CW: ROTATE_270 (which maps to 270 degrees CCW) - + res = editor._apply_edits(editor.original_image.copy()) - + # Check pixels # Original TL (Red) -> New TR # Original TR (Green) -> New BR # Original BL (Blue) -> New TL # Original BR (White) -> New BL - + w, h = res.size - + # Sample center of quadrants q_w, q_h = w // 4, h // 4 - + # New TL (Should be Blue) assert res.getpixel((q_w, q_h)) == (0, 0, 255), "TL should be Blue (was Red)" - + # New TR (Should be Red) assert res.getpixel((w - q_w, q_h)) == (255, 0, 0), "TR should be Red" @@ -262,6 +273,7 @@ def test_rotate_cw(): # New BR (Should be Green) assert res.getpixel((w - q_w, h - q_h)) == (0, 255, 0), "BR should be Green" + def test_rotate_ccw(): """Test that rotate_ccw rotates 90 degrees Counter-Clockwise.""" editor = ImageEditor() @@ -270,23 +282,23 @@ def test_rotate_ccw(): # Rotate CCW (Logic: current + 90) -> 90 editor.rotate_image_ccw() - - assert editor.current_edits['rotation'] == 90 - + + assert editor.current_edits["rotation"] == 90 + res = editor._apply_edits(editor.original_image.copy()) - + w, h = res.size q_w, q_h = w // 4, h // 4 - + # CCW Rotation: # TL (Red) -> BL # TR (Green) -> TL # BL (Blue) -> BR # BR (White) -> TR - + # New TL (Should be Green) assert res.getpixel((q_w, q_h)) == (0, 255, 0), "TL should be Green" - + # New TR (Should be White) assert res.getpixel((w - q_w, q_h)) == (255, 255, 255), "TR should be White" @@ -295,4 +307,3 @@ def test_rotate_ccw(): # New BR (Should be Blue) assert res.getpixel((w - q_w, h - q_h)) == (0, 0, 255), "BR should be Blue" - diff --git a/faststack/tests/test_esc_histogram.py b/faststack/tests/test_esc_histogram.py new file mode 100644 index 0000000..bdf9216 --- /dev/null +++ b/faststack/tests/test_esc_histogram.py @@ -0,0 +1,109 @@ +"""Tests for Esc key closing histogram behavior.""" + +from unittest.mock import MagicMock + +# Qt Key constants (avoid PySide6 import for test portability) +Key_Escape = 0x01000000 +Key_Left = 0x01000012 +Key_Right = 0x01000014 +Key_H = 0x48 +Key_Return = 0x01000004 + + +class TestEscHistogramBehavior: + """Tests for Esc key closing histogram before other actions.""" + + def test_esc_closes_histogram_when_visible(self): + """When histogram is visible, Esc should close it and consume the event.""" + # Create a mock UIState with histogram visible + mock_ui_state = MagicMock() + mock_ui_state.isHistogramVisible = True + mock_ui_state.isCropping = False + mock_ui_state.isEditorOpen = False + + # Simulate the escape handling logic from eventFilter + # This tests the core logic without needing a full AppController + def handle_esc_for_histogram(ui_state, key): + """Extracted logic from eventFilter for testing.""" + if key == Key_Escape and ui_state.isHistogramVisible: + ui_state.isHistogramVisible = False + return True # Event consumed + return False + + result = handle_esc_for_histogram(mock_ui_state, Key_Escape) + + assert result is True # Event was consumed + assert mock_ui_state.isHistogramVisible is False + + def test_esc_does_not_consume_when_histogram_hidden(self): + """When histogram is hidden, Esc should not be consumed by histogram logic.""" + mock_ui_state = MagicMock() + mock_ui_state.isHistogramVisible = False + + def handle_esc_for_histogram(ui_state, key): + """Extracted logic from eventFilter for testing.""" + if key == Key_Escape and ui_state.isHistogramVisible: + ui_state.isHistogramVisible = False + return True + return False + + result = handle_esc_for_histogram(mock_ui_state, Key_Escape) + + assert result is False # Event NOT consumed, should propagate + + def test_non_esc_keys_not_affected(self): + """Non-Esc keys should not trigger histogram close.""" + mock_ui_state = MagicMock() + mock_ui_state.isHistogramVisible = True + + def handle_esc_for_histogram(ui_state, key): + """Extracted logic from eventFilter for testing.""" + if key == Key_Escape and ui_state.isHistogramVisible: + ui_state.isHistogramVisible = False + return True + return False + + # Test with other keys + for key in [Key_Left, Key_Right, Key_H, Key_Return]: + result = handle_esc_for_histogram(mock_ui_state, key) + assert result is False + # Histogram should still be visible + assert mock_ui_state.isHistogramVisible is True + + +class TestEscHistogramPriority: + """Test that histogram close happens before grid view switch.""" + + def test_histogram_closes_without_triggering_grid_switch(self): + """Histogram should close before any grid view switch logic runs.""" + mock_ui_state = MagicMock() + mock_ui_state.isHistogramVisible = True + + # Track if grid switch was called + grid_switch_called = False + + def switch_to_grid_view(): + nonlocal grid_switch_called + grid_switch_called = True + + def handle_esc_with_priority(ui_state, key, do_grid_switch): + """Simulates the eventFilter priority: histogram first, then grid.""" + # First priority: histogram + if key == Key_Escape and ui_state.isHistogramVisible: + ui_state.isHistogramVisible = False + return True # Consume, don't continue to grid logic + + # Second priority: grid switch (only if not consumed above) + if key == Key_Escape: + do_grid_switch() + return True + + return False + + result = handle_esc_with_priority( + mock_ui_state, Key_Escape, switch_to_grid_view + ) + + assert result is True + assert mock_ui_state.isHistogramVisible is False + assert grid_switch_called is False # Grid switch NOT triggered diff --git a/faststack/tests/test_executable_validator.py b/faststack/tests/test_executable_validator.py index d69507c..124f3a5 100644 --- a/faststack/tests/test_executable_validator.py +++ b/faststack/tests/test_executable_validator.py @@ -1,6 +1,5 @@ """Tests for executable path validation.""" -import pytest from pathlib import Path from unittest.mock import patch, MagicMock @@ -28,21 +27,20 @@ def test_nonexistent_file(): def test_valid_photoshop_path(): """Test validation of a valid Photoshop path.""" photoshop_path = r"C:\Program Files\Adobe\Adobe Photoshop 2026\Photoshop.exe" - + # Mock the path checks - with patch('faststack.io.executable_validator.Path') as mock_path: + with patch("faststack.io.executable_validator.Path") as mock_path: mock_path_instance = MagicMock() mock_path.return_value.resolve.return_value = mock_path_instance mock_path_instance.exists.return_value = True mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.suffix.lower.return_value = ".exe" mock_path_instance.name = "Photoshop.exe" mock_path_instance.__str__ = lambda self: photoshop_path - - with patch('faststack.io.executable_validator._is_subpath', return_value=True): + + with patch("faststack.io.executable_validator._is_subpath", return_value=True): is_valid, error = validate_executable_path( - photoshop_path, - app_type="photoshop" + photoshop_path, app_type="photoshop" ) assert is_valid assert error is None @@ -51,34 +49,36 @@ def test_valid_photoshop_path(): def test_suspicious_path_with_traversal(): """Test that paths with directory traversal are flagged.""" suspicious_path = r"C:\Program Files\..\Windows\System32\malware.exe" - - with patch('faststack.io.executable_validator.Path') as mock_path: + + with patch("faststack.io.executable_validator.Path") as mock_path: mock_path_instance = MagicMock() mock_path.return_value.resolve.return_value = mock_path_instance mock_path_instance.exists.return_value = True mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.suffix.lower.return_value = ".exe" mock_path_instance.name = "malware.exe" mock_path_instance.__str__ = lambda self: r"C:\Windows\System32\malware.exe" - + # The normalized path will differ from input, triggering warning - with patch('faststack.io.executable_validator._is_subpath', return_value=False): + with patch("faststack.io.executable_validator._is_subpath", return_value=False): is_valid, error = validate_executable_path(suspicious_path) # Warning is logged for suspicious path, but doesn't fail with allow_custom_paths=True - assert is_valid # Default allow_custom_paths=True means it passes with warning + assert ( + is_valid + ) # Default allow_custom_paths=True means it passes with warning def test_non_exe_file(): """Test that non-executable files are rejected on Windows.""" txt_file = r"C:\Program Files\test.txt" - - with patch('faststack.io.executable_validator.Path') as mock_path: + + with patch("faststack.io.executable_validator.Path") as mock_path: mock_path_instance = MagicMock() mock_path.return_value.resolve.return_value = mock_path_instance mock_path_instance.exists.return_value = True mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.txt' - + mock_path_instance.suffix.lower.return_value = ".txt" + is_valid, error = validate_executable_path(txt_file) assert not is_valid assert "not executable" in error.lower() @@ -86,23 +86,24 @@ def test_non_exe_file(): def test_is_executable_windows(): """Test _is_executable on Windows.""" - with patch('os.name', new='nt'): + with patch("os.name", new="nt"): exe_path = MagicMock() - exe_path.suffix.lower.return_value = '.exe' + exe_path.suffix.lower.return_value = ".exe" assert _is_executable(exe_path) - + txt_path = MagicMock() - txt_path.suffix.lower.return_value = '.txt' + txt_path.suffix.lower.return_value = ".txt" assert not _is_executable(txt_path) + def test_is_subpath(): """Test _is_subpath logic.""" # This is hard to test without real paths, so we'll test the logic parent = Path(r"C:\Program Files") child = Path(r"C:\Program Files\Adobe\Photoshop.exe") - + # Mock the relative_to to simulate success - with patch.object(Path, 'resolve') as mock_resolve: + with patch.object(Path, "resolve") as mock_resolve: mock_resolve.return_value.relative_to = MagicMock() result = _is_subpath(child, parent) assert result @@ -111,20 +112,17 @@ def test_is_subpath(): def test_wrong_executable_name_for_type(): """Test that wrong executable names generate warnings but don't fail.""" wrong_exe = r"C:\Program Files\Adobe\NotPhotoshop.exe" - - with patch('faststack.io.executable_validator.Path') as mock_path: + + with patch("faststack.io.executable_validator.Path") as mock_path: mock_path_instance = MagicMock() mock_path.return_value.resolve.return_value = mock_path_instance mock_path_instance.exists.return_value = True mock_path_instance.is_file.return_value = True - mock_path_instance.suffix.lower.return_value = '.exe' + mock_path_instance.suffix.lower.return_value = ".exe" mock_path_instance.name = "NotPhotoshop.exe" mock_path_instance.__str__ = lambda self: wrong_exe - - with patch('faststack.io.executable_validator._is_subpath', return_value=True): + + with patch("faststack.io.executable_validator._is_subpath", return_value=True): # Should still pass, but with a warning logged - is_valid, error = validate_executable_path( - wrong_exe, - app_type="photoshop" - ) + is_valid, error = validate_executable_path(wrong_exe, app_type="photoshop") assert is_valid # Name mismatch is warning, not failure diff --git a/faststack/tests/test_exif_compat.py b/faststack/tests/test_exif_compat.py index 252a9ca..fba58e4 100644 --- a/faststack/tests/test_exif_compat.py +++ b/faststack/tests/test_exif_compat.py @@ -1,7 +1,6 @@ import unittest from unittest.mock import MagicMock, patch import sys -import os from pathlib import Path from PIL import Image, ExifTags import numpy as np @@ -12,24 +11,25 @@ sys.path.insert(0, project_root) # Pre-mock modules that might cause issues or aren't needed for this test -sys.modules['cv2'] = MagicMock() +sys.modules["cv2"] = MagicMock() # Mock faststack.models since it's used by editor.py mock_models = MagicMock() -sys.modules['faststack.models'] = mock_models +sys.modules["faststack.models"] = mock_models from faststack.imaging.editor import ImageEditor, sanitize_exif_orientation + class TestExifCompat(unittest.TestCase): def setUp(self): self.editor = ImageEditor() # Create a dummy image for testing - self.editor.original_image = Image.new('RGB', (10, 10)) + self.editor.original_image = Image.new("RGB", (10, 10)) self.editor._source_exif_bytes = b"dummy exif bytes" def test_missing_image_exif_attribute(self): """Test fallback when PIL.Image.Exif is missing.""" # Patching PIL.Image.Exif to raise AttributeError on access simulates it being missing - with patch('PIL.Image.Exif', side_effect=AttributeError): + with patch("PIL.Image.Exif", side_effect=AttributeError): # Also mock getexif to verify it's the fallback self.editor.original_image.getexif = MagicMock(return_value=None) self.editor._get_sanitized_exif_bytes() @@ -39,8 +39,8 @@ def test_missing_load_method(self): """Test fallback when Exif object has no load() method.""" mock_exif_instance = MagicMock() del mock_exif_instance.load - - with patch('PIL.Image.Exif', return_value=mock_exif_instance): + + with patch("PIL.Image.Exif", return_value=mock_exif_instance): # Should fall back to original_image.getexif() self.editor.original_image.getexif = MagicMock(return_value=None) self.editor._get_sanitized_exif_bytes() @@ -49,41 +49,45 @@ def test_missing_load_method(self): def test_missing_tobytes_method(self): """Test graceful failure when Exif object has no tobytes() method.""" mock_exif_instance = MagicMock() - if hasattr(mock_exif_instance, 'tobytes'): + if hasattr(mock_exif_instance, "tobytes"): del mock_exif_instance.tobytes - + # Mocking getexif to return this broken instance self.editor.original_image.getexif = MagicMock(return_value=mock_exif_instance) # Set source bytes to verify they are NOT used as fallback (safer policy) self.editor._source_exif_bytes = b"fallback bytes" - + res = self.editor._get_sanitized_exif_bytes() - self.assertIsNone(res, "Should return None if tobytes() is missing to prevent rotation issues") + self.assertIsNone( + res, "Should return None if tobytes() is missing to prevent rotation issues" + ) def test_tobytes_failure_drops_exif(self): """Verify that failure in tobytes() now returns None (drops EXIF).""" mock_exif = MagicMock() mock_exif.tobytes.side_effect = Exception("failed to serialize") - + # Patch Image.Exif to return our mock - with patch('PIL.Image.Exif', return_value=mock_exif): + with patch("PIL.Image.Exif", return_value=mock_exif): # Set source bytes self.editor._source_exif_bytes = b"fallback bytes" - + res = self.editor._get_sanitized_exif_bytes() - self.assertIsNone(res, "Should return None if tobytes() fails to prevent rotation issues") + self.assertIsNone( + res, "Should return None if tobytes() fails to prevent rotation issues" + ) def test_missing_exiftags_base(self): """Test fallback when ExifTags.Base is missing (older Pillow).""" # Patch PIL.ExifTags to be a mock that does NOT have 'Base' # This will cause ExifTags.Base to raise AttributeError - with patch('PIL.ExifTags', spec=[]): + with patch("PIL.ExifTags", spec=[]): # Use a mock that doesn't restrict attributes, but has tobytes mock_exif = MagicMock() mock_exif.tobytes = MagicMock(return_value=b"serialized exif") self.editor.original_image.getexif = MagicMock(return_value=mock_exif) self.editor._source_exif_bytes = None - + res = self.editor._get_sanitized_exif_bytes() # Check if it tried to set 0x0112 (the fallback) mock_exif.__setitem__.assert_called_with(0x0112, 1) @@ -92,26 +96,26 @@ def test_missing_exiftags_base(self): def test_sanitize_exif_orientation_helper(self): """Test the standalone sanitize_exif_orientation helper.""" # 1. Valid EXIF with Orientation=6 - img = Image.new('RGB', (10, 10)) + img = Image.new("RGB", (10, 10)) exif = img.getexif() # Use fallback if Base is not available in test env (just in case) - orientation_tag = getattr(ExifTags.Base, 'Orientation', 0x0112) + orientation_tag = getattr(ExifTags.Base, "Orientation", 0x0112) exif[orientation_tag] = 6 exif_bytes = exif.tobytes() - + sanitized = sanitize_exif_orientation(exif_bytes) self.assertIsNotNone(sanitized) - + # Verify it's now 1 loaded_exif = Image.Exif() loaded_exif.load(sanitized) self.assertEqual(loaded_exif[orientation_tag], 1) - + # 2. None input self.assertIsNone(sanitize_exif_orientation(None)) - + # 3. Invalid bytes - self.assertIsNone(sanitize_exif_orientation(b'invalid junk')) + self.assertIsNone(sanitize_exif_orientation(b"invalid junk")) def test_save_uses_sanitizer_for_sidecar(self): """Verify save_image calls sanitizer for sidecar when rotation baked in.""" @@ -119,22 +123,28 @@ def test_save_uses_sanitizer_for_sidecar(self): self.editor._source_exif_bytes = b"source_bytes" self.editor.current_filepath = Path("test.jpg") self.editor.float_image = np.zeros((10, 10, 3), dtype=np.float32) - + # Mock dependencies specifically for this test - with patch('faststack.imaging.editor.sanitize_exif_orientation') as mock_sanitize, \ - patch('faststack.imaging.editor.create_backup_file', return_value=Path("test-backup.jpg")), \ - patch('PIL.Image.fromarray') as mock_fromarray, \ - patch.object(self.editor, '_write_tiff_16bit') as mock_tiff: - + with ( + patch( + "faststack.imaging.editor.sanitize_exif_orientation" + ) as mock_sanitize, + patch( + "faststack.imaging.editor.create_backup_file", + return_value=Path("test-backup.jpg"), + ), + patch("PIL.Image.fromarray") as mock_fromarray, + patch.object(self.editor, "_write_tiff_16bit") as mock_tiff, + ): mock_img = MagicMock() mock_fromarray.return_value = mock_img - + # Action: Save with sidecar self.editor.save_image(write_developed_jpg=True) - + # Assert sanitizer was called with source bytes mock_sanitize.assert_called_with(b"source_bytes") -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/faststack/tests/test_exif_display_rotation.py b/faststack/tests/test_exif_display_rotation.py index c2dd055..940c1fa 100644 --- a/faststack/tests/test_exif_display_rotation.py +++ b/faststack/tests/test_exif_display_rotation.py @@ -1,6 +1,5 @@ """Tests for EXIF orientation correction during display.""" -import os import sys import shutil import tempfile @@ -27,7 +26,7 @@ def tearDown(self): def _create_test_image(self, filename: str, orientation: int) -> Path: """Creates a JPEG with a specific EXIF orientation. - + The image is 100x50 with red on left, blue on right. This makes it easy to verify rotation by checking pixel colors. """ @@ -35,7 +34,7 @@ def _create_test_image(self, filename: str, orientation: int) -> Path: # Create asymmetric image: 100w x 50h # Left half (0-49) = red, right half (50-99) = blue - img = Image.new('RGB', (100, 50), color='red') + img = Image.new("RGB", (100, 50), color="red") for x in range(50, 100): for y in range(50): img.putpixel((x, y), (0, 0, 255)) @@ -43,131 +42,134 @@ def _create_test_image(self, filename: str, orientation: int) -> Path: exif = img.getexif() exif[ExifTags.Base.Orientation] = orientation - img.save(path, format='JPEG', exif=exif.tobytes()) + img.save(path, format="JPEG", exif=exif.tobytes()) return path def test_orientation_1_no_change(self): """Orientation 1 (normal) should return unchanged buffer.""" path = self._create_test_image("test_ori1.jpg", 1) - + with Image.open(path) as img: original = np.array(img.convert("RGB")) - + result = apply_exif_orientation(original.copy(), path) - + self.assertEqual(result.shape, original.shape) np.testing.assert_array_equal(result, original) def test_orientation_3_rotate_180(self): """Orientation 3 should rotate 180 degrees.""" path = self._create_test_image("test_ori3.jpg", 3) - + with Image.open(path) as img: original = np.array(img.convert("RGB")) - + result = apply_exif_orientation(original.copy(), path) - + # Shape unchanged (still 50x100) self.assertEqual(result.shape, original.shape) - + # After 180 rotation, top-left should now be blue (was bottom-right) # Check that top-left pixel is blue self.assertTrue(result[0, 0, 2] > 200) # Blue channel high - self.assertTrue(result[0, 0, 0] < 50) # Red channel low + self.assertTrue(result[0, 0, 0] < 50) # Red channel low def test_orientation_6_rotate_90_cw(self): """Orientation 6 should rotate 90 degrees clockwise (270 CCW).""" path = self._create_test_image("test_ori6.jpg", 6) - + with Image.open(path) as img: original = np.array(img.convert("RGB")) - + result = apply_exif_orientation(original.copy(), path) - + # Dimensions should swap: 100x50 -> 50x100 self.assertEqual(result.shape, (100, 50, 3)) - - # After 90 CW rotation of [red-left, blue-right], + + # After 90 CW rotation of [red-left, blue-right], # top should be red, bottom should be blue # Check top-left pixel is red self.assertTrue(result[0, 0, 0] > 200) # Red channel high - self.assertTrue(result[0, 0, 2] < 50) # Blue channel low + self.assertTrue(result[0, 0, 2] < 50) # Blue channel low def test_orientation_8_rotate_90_ccw(self): """Orientation 8 should rotate 90 degrees counter-clockwise.""" path = self._create_test_image("test_ori8.jpg", 8) - + with Image.open(path) as img: original = np.array(img.convert("RGB")) - + result = apply_exif_orientation(original.copy(), path) - + # Dimensions should swap: 100x50 -> 50x100 self.assertEqual(result.shape, (100, 50, 3)) - + # After 90 CCW rotation of [red-left, blue-right], # top should be blue, bottom should be red # Check top-left pixel is blue self.assertTrue(result[0, 0, 2] > 200) # Blue channel high - self.assertTrue(result[0, 0, 0] < 50) # Red channel low + self.assertTrue(result[0, 0, 0] < 50) # Red channel low def test_orientation_2_mirror_horizontal(self): """Orientation 2 should mirror horizontally.""" path = self._create_test_image("test_ori2.jpg", 2) - + with Image.open(path) as img: original = np.array(img.convert("RGB")) - + result = apply_exif_orientation(original.copy(), path) - + # Shape unchanged self.assertEqual(result.shape, original.shape) - + # After horizontal flip, left becomes blue, right becomes red # Check top-left pixel is blue self.assertTrue(result[0, 0, 2] > 200) # Blue channel high - self.assertTrue(result[0, 0, 0] < 50) # Red channel low + self.assertTrue(result[0, 0, 0] < 50) # Red channel low def test_no_exif_returns_unchanged(self): """Image without EXIF should return unchanged buffer.""" path = Path(self.test_dir) / "no_exif.jpg" - + # Create image without EXIF - img = Image.new('RGB', (100, 50), color='green') - img.save(path, format='JPEG') - + img = Image.new("RGB", (100, 50), color="green") + img.save(path, format="JPEG") + with Image.open(path) as img: original = np.array(img.convert("RGB")) - + result = apply_exif_orientation(original.copy(), path) - + np.testing.assert_array_equal(result, original) def test_invalid_path_returns_unchanged(self): """Non-existent file should return unchanged buffer.""" path = Path(self.test_dir) / "nonexistent.jpg" - + dummy = np.zeros((50, 100, 3), dtype=np.uint8) result = apply_exif_orientation(dummy.copy(), path) - + np.testing.assert_array_equal(result, dummy) def test_orientation_contiguity(self): """Verify that the result is always C-contiguous after transformations.""" # Orientation 6 involves rotation which often results in non-contiguous arrays path = self._create_test_image("test_contiguity.jpg", 6) - + with Image.open(path) as img: original = np.array(img.convert("RGB")) - + # Ensure input is contiguous - self.assertTrue(original.flags['C_CONTIGUOUS']) - + self.assertTrue(original.flags["C_CONTIGUOUS"]) + result = apply_exif_orientation(original, path) - + # Verify the result is C-contiguous - self.assertTrue(result.flags['C_CONTIGUOUS'], "Result of apply_exif_orientation should be C-contiguous") + self.assertTrue( + result.flags["C_CONTIGUOUS"], + "Result of apply_exif_orientation should be C-contiguous", + ) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_exif_orientation.py b/faststack/tests/test_exif_orientation.py index 3f95e90..60416ad 100644 --- a/faststack/tests/test_exif_orientation.py +++ b/faststack/tests/test_exif_orientation.py @@ -1,144 +1,168 @@ - -import os import shutil import tempfile import unittest from pathlib import Path from PIL import Image, ExifTags -import numpy as np # Adjust path to import faststack from unittest.mock import MagicMock, patch import sys + # Removed global sys.modules override sys.path.append(str(Path(__file__).parents[2])) # MOVED: from faststack.imaging.editor import ImageEditor + class TestExifOrientation(unittest.TestCase): def setUp(self): # Patch sys.modules safely per-test - self.modules_patcher = patch.dict(sys.modules, {'cv2': MagicMock()}) + self.modules_patcher = patch.dict(sys.modules, {"cv2": MagicMock()}) self.modules_patcher.start() - + # Import internally to respect the patch try: from faststack.imaging.editor import ImageEditor + self.ImageEditorClass = ImageEditor except ImportError: # Fallback if path issues persist (shouldn't with sys.path.append) raise - + self.test_dir = tempfile.mkdtemp() self.editor = self.ImageEditorClass() - + def tearDown(self): self.modules_patcher.stop() shutil.rmtree(self.test_dir) # Ensure we don't pollute other tests with our mocked-import version - sys.modules.pop('faststack.imaging.editor', None) + sys.modules.pop("faststack.imaging.editor", None) def _create_test_image(self, filename, orientation=1): """Creates a dummy JPEG with specific EXIF orientation.""" path = Path(self.test_dir) / filename - + # Create a simple image: Red on left, Blue on right (to detect rotation) # 100x50 - img = Image.new('RGB', (100, 50), color='red') + img = Image.new("RGB", (100, 50), color="red") # Make right half blue for x in range(50, 100): for y in range(50): img.putpixel((x, y), (0, 0, 255)) - + exif = img.getexif() exif[ExifTags.Base.Orientation] = orientation # Add another tag to verify general EXIF preservation (e.g. ImageDescription) # 0x010E is ImageDescription exif[0x010E] = "Test Image" - - img.save(path, format='JPEG', exif=exif.tobytes()) + + img.save(path, format="JPEG", exif=exif.tobytes()) return path def test_orientation_sanitization_on_rotation(self): """Verify Orientation is reset to 1 if we rotate the image.""" for start_ori in [3, 6, 8]: with self.subTest(start_ori=start_ori): - path = self._create_test_image(f"test_rot_{start_ori}.jpg", orientation=start_ori) - + path = self._create_test_image( + f"test_rot_{start_ori}.jpg", orientation=start_ori + ) + # Load self.editor.load_image(str(path)) - + # Apply Rotation (90 degrees) - this usually rotates CCW in our pipeline # but the key is that 'transforms_applied' becomes True. - self.editor.current_edits['rotation'] = 90 - + self.editor.current_edits["rotation"] = 90 + # Save saved_path, _ = self.editor.save_image() - + # Verify with Image.open(saved_path) as res: exif = res.getexif() orientation = exif.get(ExifTags.Base.Orientation) # Should be sanitized to 1 - self.assertEqual(orientation, 1, f"Expected Orientation 1, got {orientation} for start {start_ori}") - + self.assertEqual( + orientation, + 1, + f"Expected Orientation 1, got {orientation} for start {start_ori}", + ) + # Double rotation check: if we reload this image, it should look correct # without any further rotation needed. # Start 6 (Vertical 50x100) -> Baked (50x100) -> Rotate 90 -> 100x50 # Start 8 (Vertical 50x100) -> Baked (50x100) -> Rotate 90 -> 100x50 # Start 3 (Horizontal 100x50) -> Baked (100x50) -> Rotate 90 -> 50x100 expected_size = (50, 100) if start_ori == 3 else (100, 50) - self.assertEqual(res.size, expected_size, f"Dimensions check failed for start {start_ori} with rotation") + self.assertEqual( + res.size, + expected_size, + f"Dimensions check failed for start {start_ori} with rotation", + ) def test_orientation_preserved_no_rotation(self): """Verify Orientation is PRESERVED if we do NOT rotate.""" for start_ori in [3, 6, 8]: with self.subTest(start_ori=start_ori): - path = self._create_test_image(f"test_no_rot_{start_ori}.jpg", orientation=start_ori) - + path = self._create_test_image( + f"test_no_rot_{start_ori}.jpg", orientation=start_ori + ) + # Load self.editor.load_image(str(path)) - + # Apply NO geometric edits, just color - self.editor.current_edits['exposure'] = 0.5 - + self.editor.current_edits["exposure"] = 0.5 + # Save saved_path, _ = self.editor.save_image() - + # Verify with Image.open(saved_path) as res: exif = res.getexif() orientation = exif.get(ExifTags.Base.Orientation) - + # Should be sanitized to 1 because editor now ALWAYS bakes orientation - self.assertEqual(orientation, 1, f"Orientation should be sanitized to 1, got {orientation}") - + self.assertEqual( + orientation, + 1, + f"Orientation should be sanitized to 1, got {orientation}", + ) + # Verify pixels are rotated if necessary (Start 5-8 involve 90 deg rotation or swap) # Start 3 (180) -> Same dims # Start 6 (90 CW) -> Swapped dims # Start 8 (90 CCW) -> Swapped dims if start_ori in [5, 6, 7, 8]: - self.assertEqual(res.size, (50, 100), f"Dimensions should be swapped for start {start_ori} due to baking") + self.assertEqual( + res.size, + (50, 100), + f"Dimensions should be swapped for start {start_ori} due to baking", + ) else: - self.assertEqual(res.size, (100, 50), f"Dimensions should be preserved for start {start_ori}") + self.assertEqual( + res.size, + (100, 50), + f"Dimensions should be preserved for start {start_ori}", + ) def test_raw_mode_exif_preservation(self): """Verify that camera EXIF from a source JPEG is preserved when 'developing' RAW (simulated with TIFF).""" # 1. Create a "source" JPEG with camera EXIF and Orientation=6 source_path = self._create_test_image("camera_source.jpg", orientation=6) - + with Image.open(source_path) as src: - source_exif_bytes = src.info.get('exif') + source_exif_bytes = src.info.get("exif") self.assertIsNotNone(source_exif_bytes, "Source image should have EXIF") # 2. Create a "working TIFF" (simulating developed RAW output) which lacks EXIF tiff_path = Path(self.test_dir) / "working_source.tif" - tiff_img = Image.new('RGB', (100, 50), color='green') - tiff_img.save(tiff_path, format='TIFF') - + tiff_img = Image.new("RGB", (100, 50), color="green") + tiff_img.save(tiff_path, format="TIFF") + # 3. Load TIFF into editor, passing the source EXIF self.editor.load_image(str(tiff_path), source_exif=source_exif_bytes) - + # 4. Save developed JPG WITHOUT transforms -> Orientation should be preserved (?) # Actually, RAW development usually results in an image that is visually upright # if the developer (RawTherapee) handled orientation. @@ -147,27 +171,36 @@ def test_raw_mode_exif_preservation(self): # we might get a "double rotation" IF the viewer respects EXIF. # HOWEVER, the user said: "if you do sanitize, ensure you don’t accidentally lose other tags" # and "ensure no 'double rotation' on reload". - - # If we ARE developing a RAW, we usually want to bake in the orientation + + # If we ARE developing a RAW, we usually want to bake in the orientation # or at least ensure the output is correct. - + # Let's test what happens currently: res = self.editor.save_image(write_developed_jpg=True) developed_path = Path(self.test_dir) / "working_source-developed.jpg" - + with Image.open(developed_path) as dev: exif = dev.getexif() - self.assertEqual(exif.get(ExifTags.Base.Orientation), 6, "Orientation preserved if no editor transforms") - + self.assertEqual( + exif.get(ExifTags.Base.Orientation), + 6, + "Orientation preserved if no editor transforms", + ) + # 5. Now apply an editor transform (90 deg) - self.editor.current_edits['rotation'] = 90 + self.editor.current_edits["rotation"] = 90 self.editor.save_image(write_developed_jpg=True) - + with Image.open(developed_path) as dev: exif = dev.getexif() description = exif.get(0x010E) self.assertEqual(description, "Test Image", "EXIF tags preserved") - self.assertEqual(exif.get(ExifTags.Base.Orientation), 1, "Orientation sanitized after rotation") + self.assertEqual( + exif.get(ExifTags.Base.Orientation), + 1, + "Orientation sanitized after rotation", + ) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_fallback_blur.py b/faststack/tests/test_fallback_blur.py index d42ac32..2c98e38 100644 --- a/faststack/tests/test_fallback_blur.py +++ b/faststack/tests/test_fallback_blur.py @@ -1,47 +1,48 @@ - import unittest import numpy as np -from unittest.mock import patch, MagicMock -from PIL import Image +from unittest.mock import patch # Import the functionality to test from faststack.imaging import editor + class TestFallbackBlur(unittest.TestCase): - def test_fallback_blur_logic(self): """Test that _gaussian_blur_float works even when cv2 is None""" - + # Setup a dummy float image (checkerboard) # 0.0 and 1.0 values arr = np.zeros((20, 20, 3), dtype=np.float32) arr[::2, ::2] = 1.0 - + # Calculate expected "unblurred" std dev orig_std = np.std(arr) - + # Mock cv2 to be None to force fallback path - with patch('faststack.imaging.editor.cv2', None): + with patch("faststack.imaging.editor.cv2", None): # Verify we are hitting the fallback self.assertIsNone(editor.cv2) - + # Run the blur function blurred = editor._gaussian_blur_float(arr, radius=2.0) - + # Check shape/type preservation self.assertEqual(blurred.shape, arr.shape) self.assertEqual(blurred.dtype, np.float32) - + # Check that it actually blurred # A blurred checkerboard should have lower standard deviation than the original new_std = np.std(blurred) print(f"Original Std: {orig_std:.4f}, Blurred Std: {new_std:.4f}") - - self.assertLess(new_std, orig_std, "Image should be blurred (lower variance)") - + + self.assertLess( + new_std, orig_std, "Image should be blurred (lower variance)" + ) + # Additional check: max value should decrease, min value should increase (for 0/1 checkerboard) self.assertLess(blurred.max(), 1.0) self.assertGreater(blurred.min(), 0.0) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_file_locking.py b/faststack/tests/test_file_locking.py index 30a5174..137163d 100644 --- a/faststack/tests/test_file_locking.py +++ b/faststack/tests/test_file_locking.py @@ -1,36 +1,36 @@ """Tests for file locking handling in undo operations.""" + import unittest -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock, patch from pathlib import Path import tempfile import shutil -import os class TestRestoreBackupSafe(unittest.TestCase): """Tests for _restore_backup_safe method without mocking.""" - + def setUp(self): """Create temp directory with test files.""" self.temp_dir = tempfile.mkdtemp() self.saved_path = Path(self.temp_dir) / "test_image.jpg" self.backup_path = Path(self.temp_dir) / "test_image.jpg.backup" - + # Create a backup file with content self.backup_path.write_bytes(b"backup content") - + def tearDown(self): """Clean up temp directory.""" shutil.rmtree(self.temp_dir, ignore_errors=True) - + def _make_controller(self): """Create a minimal controller with just what _restore_backup_safe needs.""" # We can't easily instantiate AppController, so we'll test the logic directly # by calling the function with a mock self from faststack.app import AppController - + # Patch __init__ to skip complex initialization - with patch.object(AppController, '__init__', return_value=None): + with patch.object(AppController, "__init__", return_value=None): controller = AppController() controller.update_status_message = MagicMock() return controller @@ -38,13 +38,15 @@ def _make_controller(self): def test_simple_restore_no_target(self): """Test restoring backup when target doesn't exist.""" controller = self._make_controller() - + # Target doesn't exist, backup exists self.assertFalse(self.saved_path.exists()) self.assertTrue(self.backup_path.exists()) - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - + + result = controller._restore_backup_safe( + str(self.saved_path), str(self.backup_path) + ) + self.assertTrue(result) self.assertTrue(self.saved_path.exists()) self.assertFalse(self.backup_path.exists()) @@ -53,12 +55,14 @@ def test_simple_restore_no_target(self): def test_restore_replaces_target(self): """Test restoring backup when target already exists (replaced cleanly).""" controller = self._make_controller() - + # Create both files self.saved_path.write_bytes(b"old content") - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - + + result = controller._restore_backup_safe( + str(self.saved_path), str(self.backup_path) + ) + self.assertTrue(result) self.assertTrue(self.saved_path.exists()) self.assertFalse(self.backup_path.exists()) @@ -67,21 +71,25 @@ def test_restore_replaces_target(self): def test_backup_not_found(self): """Test handling when backup file doesn't exist.""" controller = self._make_controller() - + # Remove backup self.backup_path.unlink() - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - + + result = controller._restore_backup_safe( + str(self.saved_path), str(self.backup_path) + ) + self.assertFalse(result) controller.update_status_message.assert_called() def test_verification_after_move(self): """Test that the method verifies the file exists after move.""" controller = self._make_controller() - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - + + result = controller._restore_backup_safe( + str(self.saved_path), str(self.backup_path) + ) + self.assertTrue(result) # File must exist and have content self.assertTrue(self.saved_path.exists()) @@ -90,16 +98,18 @@ def test_verification_after_move(self): def test_unique_temp_path_used(self): """Test that unique temp paths don't collide with existing files.""" controller = self._make_controller() - + # Create a file that would collide with a fixed .tmp_restore suffix - collision_path = self.saved_path.with_suffix('.tmp_restore') + collision_path = self.saved_path.with_suffix(".tmp_restore") collision_path.write_bytes(b"collision content") - + # Create target to force the locked-file path self.saved_path.write_bytes(b"old content") - - result = controller._restore_backup_safe(str(self.saved_path), str(self.backup_path)) - + + result = controller._restore_backup_safe( + str(self.saved_path), str(self.backup_path) + ) + self.assertTrue(result) # Collision file should be untouched self.assertTrue(collision_path.exists()) @@ -108,30 +118,30 @@ def test_unique_temp_path_used(self): class TestUndoDeleteVerification(unittest.TestCase): """Integration tests for restore_file verification in undo_delete.""" - + def setUp(self): self.temp_dir = tempfile.mkdtemp() - + def tearDown(self): shutil.rmtree(self.temp_dir, ignore_errors=True) - + def test_restore_file_verifies_success(self): """Test that restore_file nested function verifies shutil.move succeeded.""" src_path = Path(self.temp_dir) / "source.jpg" bin_path = Path(self.temp_dir) / "bin" / "source.jpg" - + # Create bin directory and file bin_path.parent.mkdir(parents=True, exist_ok=True) bin_path.write_bytes(b"test content") - + # Move it shutil.move(str(bin_path), str(src_path)) - + # Verify it worked self.assertTrue(src_path.exists()) self.assertFalse(bin_path.exists()) self.assertEqual(src_path.read_bytes(), b"test content") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/faststack/tests/test_generation_aware_preview.py b/faststack/tests/test_generation_aware_preview.py index dfa3fbf..76c05b5 100644 --- a/faststack/tests/test_generation_aware_preview.py +++ b/faststack/tests/test_generation_aware_preview.py @@ -1,7 +1,5 @@ - import unittest -from unittest.mock import MagicMock, patch -from PySide6.QtCore import QObject +from unittest.mock import MagicMock from PySide6.QtGui import QImage # Import the class to test (assuming it's importable) @@ -10,10 +8,11 @@ import os # Adjust path to allow imports -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from faststack.ui.provider import ImageProvider + class TestGenerationAwarePreview(unittest.TestCase): def setUp(self): self.mock_controller = MagicMock() @@ -21,17 +20,17 @@ def setUp(self): self.mock_controller.ui_state.isEditorOpen = True self.mock_controller.ui_state.isZoomed = False self.mock_controller.current_index = 0 - + # Setup mock images self.mock_preview = MagicMock() - self.mock_preview.buffer = b'\x00' * 100 + self.mock_preview.buffer = b"\x00" * 100 self.mock_preview.width = 10 self.mock_preview.height = 10 self.mock_preview.bytes_per_line = 30 self.mock_preview.format = QImage.Format.Format_RGB888 - + self.mock_decoded = MagicMock() - self.mock_decoded.buffer = b'\xFF' * 100 + self.mock_decoded.buffer = b"\xff" * 100 self.mock_decoded.width = 10 self.mock_decoded.height = 10 self.mock_decoded.bytes_per_line = 30 @@ -39,7 +38,7 @@ def setUp(self): self.mock_controller._last_rendered_preview = self.mock_preview self.mock_controller.get_decoded_image.return_value = self.mock_decoded - + self.provider = ImageProvider(self.mock_controller) def test_matching_generation(self): @@ -47,52 +46,52 @@ def test_matching_generation(self): # Setup matching state self.mock_controller._last_rendered_preview_index = 0 self.mock_controller._last_rendered_preview_gen = 5 - + # Request with matching generation img = self.provider.requestImage("0/5", None, None) - + # Should be the preview (dark gray placeholder if fails, but here we mocked QImage creation?) # Wait, requestImage creates a QImage from the buffer. # We check WHICH buffer was used. # Since we cannot easily check the pixels of the returned QImage without a GUI instance, # we can check if get_decoded_image was called. - + # If it used preview, get_decoded_image should NOT be called (or only if preview is None) # But wait, logic is: # image_data = self.app_controller._last_rendered_preview if use_editor_preview else self.app_controller.get_decoded_image(index) - + # So we reset the mock self.mock_controller.get_decoded_image.reset_mock() - + self.provider.requestImage("0/5", None, None) - + self.mock_controller.get_decoded_image.assert_not_called() - + def test_mismatched_generation(self): """Should fallback to decoded image when generation does not match.""" # Setup state: preview is old (gen 4) self.mock_controller._last_rendered_preview_index = 0 self.mock_controller._last_rendered_preview_gen = 4 - + # Request new generation (5) self.mock_controller.get_decoded_image.reset_mock() - + self.provider.requestImage("0/5", None, None) - + self.mock_controller.get_decoded_image.assert_called_with(0) def test_mismatched_index(self): """Should fallback when index does not match.""" self.mock_controller._last_rendered_preview_index = 1 self.mock_controller._last_rendered_preview_gen = 5 - + self.mock_controller.get_decoded_image.reset_mock() self.provider.requestImage("0/5", None, None) - + self.mock_controller.get_decoded_image.assert_called_with(0) def test_no_generation_checking_if_not_provided(self): - """If generation not provided in ID, should ignore tracking? + """If generation not provided in ID, should ignore tracking? The code says: (gen is None or getattr(...) == gen) If ID is '0', gen is None. (None is None) is True. So it matches. @@ -100,12 +99,13 @@ def test_no_generation_checking_if_not_provided(self): """ self.mock_controller._last_rendered_preview_index = 0 self.mock_controller._last_rendered_preview_gen = 99 - + self.mock_controller.get_decoded_image.reset_mock() # Request without generation self.provider.requestImage("0", None, None) - + self.mock_controller.get_decoded_image.assert_not_called() -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_headroom_semantics.py b/faststack/tests/test_headroom_semantics.py index 95d1da4..e73ab23 100644 --- a/faststack/tests/test_headroom_semantics.py +++ b/faststack/tests/test_headroom_semantics.py @@ -5,14 +5,15 @@ from unittest.mock import MagicMock # Mock cv2/turbojpeg -sys.modules['cv2'] = MagicMock() -sys.modules['turbojpeg'] = MagicMock() -sys.modules['PyTurboJPEG'] = MagicMock() +sys.modules["cv2"] = MagicMock() +sys.modules["turbojpeg"] = MagicMock() +sys.modules["PyTurboJPEG"] = MagicMock() # Ensure faststack is in path -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from faststack.imaging.editor import _analyze_highlight_state -from faststack.imaging.editor import ImageEditor, _analyze_highlight_state, _srgb_to_linear class TestHeadroomSemantics(unittest.TestCase): def test_headroom_exposure_independence(self): @@ -20,42 +21,51 @@ def test_headroom_exposure_independence(self): # 1. Create a synthetic image with max=1.0 (No headroom) # Linear space: 1.0 img = np.ones((100, 100, 3), dtype=np.float32) * 1.0 - + # 2. Analyze state with NO exposure change # Pre-exposure is same as input state = _analyze_highlight_state(img, pre_exposure_linear=img) - self.assertEqual(state['headroom_pct'], 0.0) + self.assertEqual(state["headroom_pct"], 0.0) # 3. Simulate High Exposure (+1 EV -> 2x gain) # Current linear becomes 2.0 exposed_img = img * 2.0 - + # Analyze: pass exposed as 'rgb_linear' but original as 'pre_exposure_linear' state_exposed = _analyze_highlight_state(exposed_img, pre_exposure_linear=img) - + # Headroom should STILL be 0.0 because pre-exposure < 1.0 - self.assertEqual(state_exposed['headroom_pct'], 0.0, "Headroom should not be triggering just because of exposure") - + self.assertEqual( + state_exposed["headroom_pct"], + 0.0, + "Headroom should not be triggering just because of exposure", + ) + # 4. Reference: If we didn't pass pre-exposure, it WOULD show headroom state_naive = _analyze_highlight_state(exposed_img, pre_exposure_linear=None) - self.assertGreater(state_naive['headroom_pct'], 0.99, "Naive analysis should show headroom (sanity check)") + self.assertGreater( + state_naive["headroom_pct"], + 0.99, + "Naive analysis should show headroom (sanity check)", + ) def test_true_headroom_detection(self): """Verify actual headroom is detected regardless of exposure.""" # 1. Image with real headroom (max=1.5) img = np.ones((100, 100, 3), dtype=np.float32) * 1.5 - + # 2. Even if we darken it (-1 EV -> 0.75), we should know it HAD headroom? # Typically "headroom" implies "recoverable data". # If I underexpose a RAW, I still want to know it has headroom. - # Actually, if I underexpose, the values become < 1.0. + # Actually, if I underexpose, the values become < 1.0. # But the SOURCE has values > 1.0. # So yes, headroom_pct should ideally reflect source capability. - + darkened_img = img * 0.5 - + state = _analyze_highlight_state(darkened_img, pre_exposure_linear=img) - self.assertGreater(state['headroom_pct'], 0.99) + self.assertGreater(state["headroom_pct"], 0.99) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_highlight_recovery.py b/faststack/tests/test_highlight_recovery.py index 84d6f95..6826f4e 100644 --- a/faststack/tests/test_highlight_recovery.py +++ b/faststack/tests/test_highlight_recovery.py @@ -5,17 +5,22 @@ - Uses adaptive parameters based on headroom and clipping - Handles both 16-bit (headroom) and 8-bit (JPEG) sources """ + import sys import os + # Add parent directory to path for standalone execution -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +sys.path.insert( + 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +) # Mock cv2 if not available (for test environments) try: import cv2 except ImportError: from unittest import mock - sys.modules['cv2'] = mock.MagicMock() + + sys.modules["cv2"] = mock.MagicMock() import numpy as np import time @@ -25,7 +30,6 @@ _highlight_boost_linear, _apply_headroom_shoulder, _analyze_highlight_state, - _smoothstep01, ) @@ -33,15 +37,17 @@ def test_monotonicity(): """Gradient 0→2.0 should be non-decreasing after recovery.""" # Create gradient with headroom gradient = np.linspace(0, 2.0, 100).reshape(10, 10) - rgb = np.stack([gradient, gradient * 0.5, gradient * 0.3], axis=2).astype(np.float32) - + rgb = np.stack([gradient, gradient * 0.5, gradient * 0.3], axis=2).astype( + np.float32 + ) + recovered = _highlight_recover_linear(rgb, amount=1.0, pivot=0.5) - + # Check max-channel brightness is non-decreasing brightness = recovered.max(axis=2).flatten() diffs = np.diff(brightness) eps = 1e-7 - + assert np.all(diffs >= -eps), f"Monotonicity violated: min diff = {diffs.min()}" print("test_monotonicity passed") @@ -49,23 +55,25 @@ def test_monotonicity(): def test_no_nan_inf(): """Random input including edge cases should produce finite output.""" np.random.seed(42) - + # Include zeros, ones, headroom, and extreme values test_cases = [ np.random.rand(50, 50, 3).astype(np.float32), # Normal np.zeros((10, 10, 3), dtype=np.float32), # All zeros np.ones((10, 10, 3), dtype=np.float32), # All ones np.ones((10, 10, 3), dtype=np.float32) * 2.0, # Headroom - np.array([[[0, 0, 0], [1e-10, 1e-10, 1e-10], [10.0, 5.0, 2.0]]], dtype=np.float32), # Edge cases + np.array( + [[[0, 0, 0], [1e-10, 1e-10, 1e-10], [10.0, 5.0, 2.0]]], dtype=np.float32 + ), # Edge cases ] - + for i, arr in enumerate(test_cases): recovered = _highlight_recover_linear(arr, amount=1.0, pivot=0.5) assert np.isfinite(recovered).all(), f"NaN/inf in test case {i}" - + boosted = _highlight_boost_linear(arr, amount=1.0, pivot=0.5) assert np.isfinite(boosted).all(), f"NaN/inf in boost test case {i}" - + print("test_no_nan_inf passed") @@ -73,24 +81,28 @@ def test_hue_preservation(): """Saturated highlight ramp should preserve RGB ratios (hue).""" # Create saturated red gradient with headroom brightness = np.linspace(0.1, 2.0, 50).reshape(5, 10) - rgb = np.stack([brightness, brightness * 0.2, brightness * 0.2], axis=2).astype(np.float32) - - recovered = _highlight_recover_linear(rgb, amount=0.8, pivot=0.5, chroma_rolloff=0.0) - + rgb = np.stack([brightness, brightness * 0.2, brightness * 0.2], axis=2).astype( + np.float32 + ) + + recovered = _highlight_recover_linear( + rgb, amount=0.8, pivot=0.5, chroma_rolloff=0.0 + ) + # Check R:G:B ratios where brightness > 0.01 orig_brightness = rgb.max(axis=2) mask = orig_brightness > 0.01 - + if np.any(mask): # Normalize to get ratio orig_norm = rgb[mask] / (orig_brightness[mask, None] + 1e-7) rec_brightness = recovered.max(axis=2) rec_norm = recovered[mask] / (rec_brightness[mask, None] + 1e-7) - + # Ratios should be within 5% ratio_diff = np.abs(orig_norm - rec_norm).max() assert ratio_diff < 0.05, f"Hue shift too large: {ratio_diff}" - + print("test_hue_preservation passed") @@ -98,13 +110,13 @@ def test_mask_isolation(): """Pixels with max-channel below pivot should barely change.""" # Create image with values below and above pivot low = np.ones((10, 10, 3), dtype=np.float32) * 0.3 # Below pivot 0.5 - + recovered = _highlight_recover_linear(low, amount=1.0, pivot=0.5) - + # Changes should be minimal diff = np.abs(recovered - low).max() assert diff < 1e-4, f"Below-pivot pixels changed by {diff}" - + print("test_mask_isolation passed") @@ -112,13 +124,13 @@ def test_plateau_stability(): """Clipped [1,1,1] region should stay uniform after recovery (no ringing).""" # Uniform white plateau plateau = np.ones((20, 20, 3), dtype=np.float32) - + recovered = _highlight_recover_linear(plateau, amount=1.0, pivot=0.5) - + # All pixels should be the same (uniform) std = recovered.std() assert std < 1e-6, f"Plateau became non-uniform: std = {std}" - + print("test_plateau_stability passed") @@ -126,19 +138,19 @@ def test_headroom_shoulder(): """Global shoulder should compress values > 1.0 correctly.""" x = np.array([0.5, 1.0, 1.5, 2.0, 5.0], dtype=np.float32) out = _apply_headroom_shoulder(x, max_overshoot=0.05) - + # f(x) for x <= 1 should be unchanged assert out[0] == 0.5 assert out[1] == 1.0 - + # f(x) for x > 1 should be > 1 but < x for i in range(2, len(x)): assert out[i] > 1.0, f"Value at {x[i]} should be > 1.0, got {out[i]}" assert out[i] < x[i], f"Value at {x[i]} should be compressed, got {out[i]}" - + # Should be monotonic assert np.all(np.diff(out) >= 0), "Shoulder is not monotonic" - + print("test_headroom_shoulder passed") @@ -147,13 +159,17 @@ def test_analyze_highlight_state(): # Image with headroom headroom_img = np.ones((10, 10, 3), dtype=np.float32) * 1.5 state = _analyze_highlight_state(headroom_img) - assert state['headroom_pct'] > 0.9, f"Should detect headroom: {state['headroom_pct']}" - + assert state["headroom_pct"] > 0.9, ( + f"Should detect headroom: {state['headroom_pct']}" + ) + # Normal image normal_img = np.ones((10, 10, 3), dtype=np.float32) * 0.5 state = _analyze_highlight_state(normal_img) - assert state['headroom_pct'] < 0.01, f"Should not detect headroom: {state['headroom_pct']}" - + assert state["headroom_pct"] < 0.01, ( + f"Should not detect headroom: {state['headroom_pct']}" + ) + print("test_analyze_highlight_state passed") @@ -161,26 +177,26 @@ def test_source_clipping_detection(): """Verify that srgb_u8 correctly influences clipping results even if linear is dimmed.""" # 1. Create a "clipped" source image (uint8) srgb_u8 = np.ones((10, 10, 3), dtype=np.uint8) * 255 - + # 2. Create a "dimmed" linear image (it was clipped in source, but exposure pulled it down) # Even though it's 0.2, it WAS clipped at the source. rgb_linear = np.ones((10, 10, 3), dtype=np.float32) * 0.2 - + # 3. Analyze WITHOUT srgb_u8 -> should report 0 clipping because 0.2 < threshold state_no_u8 = _analyze_highlight_state(rgb_linear, srgb_u8=None) - assert state_no_u8['source_clipped_pct'] == 0.0 - + assert state_no_u8["source_clipped_pct"] == 0.0 + # 4. Analyze WITH srgb_u8 -> should report 100% clipping because srgb_u8 is 255 state_with_u8 = _analyze_highlight_state(rgb_linear, srgb_u8=srgb_u8) - assert state_with_u8['source_clipped_pct'] == 1.0 - + assert state_with_u8["source_clipped_pct"] == 1.0 + print("test_source_clipping_detection passed") def test_benchmark(): """1920x1080 should be processed in reasonable time (vectorized).""" arr = np.random.rand(1080, 1920, 3).astype(np.float32) - + # Warm up _highlight_recover_linear(arr, amount=0.5, pivot=0.5) @@ -189,8 +205,8 @@ def test_benchmark(): for _ in range(3): _highlight_recover_linear(arr, amount=0.5, pivot=0.5) elapsed = (time.perf_counter() - start) / 3 - - print(f"test_benchmark: 1920x1080 recovery in {elapsed*1000:.1f}ms") + + print(f"test_benchmark: 1920x1080 recovery in {elapsed * 1000:.1f}ms") # Informational only - no hard assertion for CI stability @@ -209,5 +225,6 @@ def test_benchmark(): except Exception as e: print(f"\nTEST FAILED: {e}") import traceback + traceback.print_exc() exit(1) diff --git a/faststack/tests/test_highlight_state_normalization.py b/faststack/tests/test_highlight_state_normalization.py index 15ac9e4..9876e48 100644 --- a/faststack/tests/test_highlight_state_normalization.py +++ b/faststack/tests/test_highlight_state_normalization.py @@ -1,8 +1,10 @@ """Unit test for highlightState normalization in UIState.""" + import unittest from unittest.mock import MagicMock from faststack.ui.provider import UIState + class TestUIStateNormalization(unittest.TestCase): def setUp(self): # Mock app_controller and image_editor @@ -11,53 +13,46 @@ def setUp(self): self.mock_controller.image_editor = self.mock_editor self.ui_state = UIState(self.mock_controller) - def test_highlight_state_normalization_standard(self): - """Test with standard keys.""" + def test_highlight_state_normalization_legacy_keys(self): + """Test with legacy keys.""" self.mock_editor._last_highlight_state = { - 'headroom_pct': 0.1, - 'clipped_pct': 0.2, - 'near_white_pct': 0.3 + "headroom_pct": 0.1, + "clipped_pct": 0.2, + "near_white_pct": 0.3, } - # Controller returns canonical keys using the passed dict (even if they were wrong in backend, provider normalizes? - # NO, provider simply gets what is in the dict. - # Wait, provider logic: - # return { - # 'headroom_pct': state.get('headroom_pct', 0.0), - # 'source_clipped_pct': state.get('source_clipped_pct', 0.0), - # 'current_nearwhite_pct': state.get('current_nearwhite_pct', 0.0) - # } - # So if backend has OLD keys, provider will return 0.0 for new keys! - # This confirms that backend MUST populate new keys. - + state = self.ui_state.highlightState + self.assertEqual(state["headroom_pct"], 0.1) + self.assertEqual(state["source_clipped_pct"], 0.0) + self.assertEqual(state["current_nearwhite_pct"], 0.0) + def test_highlight_state_normalization_standard(self): """Test with canonical keys present.""" self.mock_editor._last_highlight_state = { - 'headroom_pct': 0.1, - 'source_clipped_pct': 0.4, - 'current_nearwhite_pct': 0.5 + "headroom_pct": 0.1, + "source_clipped_pct": 0.4, + "current_nearwhite_pct": 0.5, } state = self.ui_state.highlightState - self.assertEqual(state['headroom_pct'], 0.1) - self.assertEqual(state['source_clipped_pct'], 0.4) - self.assertEqual(state['current_nearwhite_pct'], 0.5) + self.assertEqual(state["headroom_pct"], 0.1) + self.assertEqual(state["source_clipped_pct"], 0.4) + self.assertEqual(state["current_nearwhite_pct"], 0.5) def test_highlight_state_normalization_empty(self): """Test with empty state.""" self.mock_editor._last_highlight_state = None state = self.ui_state.highlightState - self.assertEqual(state['headroom_pct'], 0.0) - self.assertEqual(state['source_clipped_pct'], 0.0) - self.assertEqual(state['current_nearwhite_pct'], 0.0) + self.assertEqual(state["headroom_pct"], 0.0) + self.assertEqual(state["source_clipped_pct"], 0.0) + self.assertEqual(state["current_nearwhite_pct"], 0.0) def test_highlight_state_normalization_missing_keys(self): """Test with missing keys.""" - self.mock_editor._last_highlight_state = { - 'headroom_pct': 0.1 - } + self.mock_editor._last_highlight_state = {"headroom_pct": 0.1} state = self.ui_state.highlightState - self.assertEqual(state['headroom_pct'], 0.1) - self.assertEqual(state['source_clipped_pct'], 0.0) - self.assertEqual(state['current_nearwhite_pct'], 0.0) + self.assertEqual(state["headroom_pct"], 0.1) + self.assertEqual(state["source_clipped_pct"], 0.0) + self.assertEqual(state["current_nearwhite_pct"], 0.0) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_highlights_responsiveness.py b/faststack/tests/test_highlights_responsiveness.py index 304b9ca..944c80e 100644 --- a/faststack/tests/test_highlights_responsiveness.py +++ b/faststack/tests/test_highlights_responsiveness.py @@ -5,32 +5,33 @@ from unittest.mock import MagicMock # Mock dependencies -sys.modules['cv2'] = MagicMock() -sys.modules['turbojpeg'] = MagicMock() -sys.modules['PyTurboJPEG'] = MagicMock() +sys.modules["cv2"] = MagicMock() +sys.modules["turbojpeg"] = MagicMock() +sys.modules["PyTurboJPEG"] = MagicMock() -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) from faststack.imaging.editor import ImageEditor + class TestHighlightsResponsiveness(unittest.TestCase): def test_highlights_at_various_levels(self): """Test how much highlights recovery affects various brightness levels.""" editor = ImageEditor() - + # Create a gradient from 0.0 to 1.0 (linear) # 0.5 linear is about 186/255 in sRGB # 0.25 linear is about 137/255 in sRGB steps = 11 vals = np.linspace(0.0, 1.0, steps, dtype=np.float32) - linear = np.stack([vals]*3, axis=-1).reshape(1, steps, 3) - + linear = np.stack([vals] * 3, axis=-1).reshape(1, steps, 3) + # Apply edits with highlights at -1.0 (max recovery) edits = editor._initial_edits() - edits['highlights'] = -1.0 - + edits["highlights"] = -1.0 + out = editor._apply_edits(linear.copy(), edits=edits, for_export=True) - + print("\nBrightness Levels (Linear 0.0 -> 1.0):") print("Input -> Output (Diff)") for i in range(steps): @@ -38,9 +39,10 @@ def test_highlights_at_various_levels(self): outp = out[0, i, 0] diff = inp - outp print(f"{inp:0.2f} -> {outp:0.4f} ({diff:0.4f})") - + # The goal is to see significant changes (diff > 0.01) starting from lower levels # Currently, with pivot 0.75, values below 0.75 should be unchanged (diff=0) - -if __name__ == '__main__': + + +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_highlights_v2.py b/faststack/tests/test_highlights_v2.py index bcc8268..58f2801 100644 --- a/faststack/tests/test_highlights_v2.py +++ b/faststack/tests/test_highlights_v2.py @@ -1,20 +1,20 @@ import unittest import numpy as np + # Adjust import path if necessary, but faststack is likely installed or in pythonpath import sys import os from unittest.mock import MagicMock + # Mock cv2 before importing faststack modules that depend on it -sys.modules['cv2'] = MagicMock() -sys.modules['turbojpeg'] = MagicMock() -sys.modules['PyTurboJPEG'] = MagicMock() +sys.modules["cv2"] = MagicMock() +sys.modules["turbojpeg"] = MagicMock() +sys.modules["PyTurboJPEG"] = MagicMock() -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) from faststack.imaging.editor import ImageEditor, _apply_headroom_shoulder -from faststack.ui.provider import UIState -from faststack.app import AppController -from PySide6.QtCore import QObject, Signal + class TestHighlightsV2(unittest.TestCase): def test_shoulder_asymptote(self): @@ -22,17 +22,17 @@ def test_shoulder_asymptote(self): x = np.array([1.0, 2.0, 10.0, 100.0], dtype=np.float32) max_overshoot = 0.05 out = _apply_headroom_shoulder(x, max_overshoot=max_overshoot) - + # At 1.0, should be 1.0 self.assertAlmostEqual(out[0], 1.0, places=5) - + # Above 1.0, should be < 1.0 + max_overshoot self.assertTrue(np.all(out[1:] < 1.0 + max_overshoot)) - + # Monotonicity self.assertTrue(out[1] > out[0]) self.assertTrue(out[2] > out[1]) - + # Asymptote check: at very large x, should be close to 1.05 self.assertAlmostEqual(out[-1], 1.0 + max_overshoot, delta=0.001) @@ -43,51 +43,53 @@ def test_analysis_decoupling(self): linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2 # sRGB mock indicating some clipping (e.g. 255) srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255 - + # Setup editor state to simulate the image being loaded # We need this because _apply_edits works on self.float_image/preview logic usually, - # but one can pass arr. + # but one can pass arr. # But _apply_edits updates _last_highlight_state. - + # Run _apply_edits flow edits = editor._initial_edits() - edits['highlights'] = -0.5 - + edits["highlights"] = -0.5 + # _apply_edits expects global self.float_image for some contexts? # No, it takes img_arr arg. - + editor._apply_edits(linear, edits=edits, for_export=False) - + # Check cache self.assertIsNotNone(editor._last_highlight_state) # Note: update logic might use striding so check rough values - self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9) + self.assertGreater(editor._last_highlight_state["headroom_pct"], 0.9) def test_robust_ceiling(self): """Verify headroom ceiling handles hot pixels.""" try: editor = ImageEditor() - linear = np.ones((100, 100, 3), dtype=np.float32) * 1.1 # Moderate headroom + linear = np.ones((100, 100, 3), dtype=np.float32) * 1.1 # Moderate headroom # Add a single hot pixel linear[50, 50, :] = 1000.0 - + # Use highlights recovery, ensuring we pass srgb_u8 if needed by analysis # (Though robust ceiling logic is in the adjustment phase, analysis happens first) editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) - + # Check that we didn't explode or crash # The result is returned by _apply_highlights_shadows, but editor doesn't store it in place of input? # Wait, editor method returns new array. out = editor._apply_highlights_shadows(linear, highlights=-1.0, shadows=0.0) - + self.assertTrue(np.isfinite(out).all()) # The hot pixel should be compressed but not NaN self.assertLess(out[50, 50, 0], 1000.0) except Exception: import traceback import sys + traceback.print_exc(file=sys.__stderr__) - raise + raise + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_metadata.py b/faststack/tests/test_metadata.py index 9618651..54c6fdb 100644 --- a/faststack/tests/test_metadata.py +++ b/faststack/tests/test_metadata.py @@ -1,51 +1,53 @@ - import unittest from unittest.mock import MagicMock, patch from pathlib import Path from faststack.imaging.metadata import get_exif_data, clean_exif_value from PIL import ExifTags + class TestMetadata(unittest.TestCase): - @patch('pathlib.Path.exists', return_value=True) - @patch('faststack.imaging.metadata.Image.open') + @patch("pathlib.Path.exists", return_value=True) + @patch("faststack.imaging.metadata.Image.open") def test_get_exif_data_success(self, mock_open, mock_exists): try: # Setup mock image and exif data mock_img = MagicMock() - + # Create a reverse mapping for tags to IDs for easier setup tag_map = {v: k for k, v in ExifTags.TAGS.items()} - + exif_dict = { tag_map["DateTimeOriginal"]: "2023:01:01 12:00:00", - tag_map["Make"]: "Canon\x00", # Null terminated + tag_map["Make"]: "Canon\x00", # Null terminated tag_map["Model"]: "Canon EOS R5", tag_map["LensModel"]: "RF 24-70mm F2.8L IS USM", tag_map["ISOSpeedRatings"]: 100, - tag_map["FNumber"]: (28, 10), # f/2.8 - tag_map["ExposureTime"]: (1, 200), # 1/200s - tag_map["FocalLength"]: (50, 1), # 50mm - tag_map["MakerNote"]: b'Some binary data \x00\x01\x02', # Binary data - tag_map["UserComment"]: b'ASCII comment\x00', # ASCII bytes - tag_map["Flash"]: 1, # Fired + tag_map["FNumber"]: (28, 10), # f/2.8 + tag_map["ExposureTime"]: (1, 200), # 1/200s + tag_map["FocalLength"]: (50, 1), # 50mm + tag_map["MakerNote"]: b"Some binary data \x00\x01\x02", # Binary data + tag_map["UserComment"]: b"ASCII comment\x00", # ASCII bytes + tag_map["Flash"]: 1, # Fired tag_map["GPSInfo"]: { - 1: 'N', - 2: (34.0, 0.0, 0.0), # 34 deg N - 3: 'W', - 4: (118.0, 15.0, 0.0) # 118 deg 15 min W - } + 1: "N", + 2: (34.0, 0.0, 0.0), # 34 deg N + 3: "W", + 4: (118.0, 15.0, 0.0), # 118 deg 15 min W + }, } - + mock_img._getexif.return_value = exif_dict mock_open.return_value = mock_img - + # Test result = get_exif_data(Path("dummy.jpg")) - + # Verify summary summary = result["summary"] self.assertEqual(summary["Date Taken"], "2023:01:01 12:00:00") - self.assertEqual(summary["Camera"], "Canon EOS R5") # Make should be collapsed into Model + self.assertEqual( + summary["Camera"], "Canon EOS R5" + ) # Make should be collapsed into Model self.assertEqual(summary["Lens"], "RF 24-70mm F2.8L IS USM") self.assertEqual(summary["ISO"], "100") self.assertEqual(summary["Aperture"], "f/2.8") @@ -55,16 +57,17 @@ def test_get_exif_data_success(self, mock_open, mock_exists): # 34 + 0/60 + 0/3600 = 34.00000 # 118 + 15/60 + 0/3600 = 118.25000 -> -118.25000 (W) self.assertEqual(summary["GPS"], "34.00000, -118.25000") - + # Verify full data contains keys and handles binary full = result["full"] self.assertIn("DateTimeOriginal", full) self.assertEqual(full["Model"], "Canon EOS R5") self.assertTrue(full["MakerNote"].startswith(" strength = 1.0 arr_reasonable = np.linspace(50, 200, 10000, dtype=np.uint8).reshape(100, 100) img_reasonable = Image.fromarray(arr_reasonable) self.editor.original_image = img_reasonable self.editor._preview_image = img_reasonable - + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - + # Calculate what strength should be based on stretch factor dynamic_range = p_high - p_low stretch_full = 255.0 / dynamic_range STRETCH_CAP = 4.0 - + if stretch_full <= STRETCH_CAP: expected_strength = 1.0 else: expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) - - print(f"Reasonable range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, " - f"stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") - + + print( + f"Reasonable range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, " + f"stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}" + ) + # For reasonable range, should use full strength self.assertAlmostEqual(expected_strength, 1.0, places=2) - + # Test case 2: Low dynamic range (100-140, range=40) # Expected: stretch = 255/40 = 6.375x (> 4x cap) => strength = 3/5.375 ≈ 0.558 - arr_low_range = np.clip(np.linspace(100, 140, 10000, dtype=np.uint8), 100, 140).reshape(100, 100) + arr_low_range = np.clip( + np.linspace(100, 140, 10000, dtype=np.uint8), 100, 140 + ).reshape(100, 100) img_low_range = Image.fromarray(arr_low_range) self.editor.original_image = img_low_range self.editor._preview_image = img_low_range - + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - + dynamic_range = p_high - p_low stretch_full = 255.0 / dynamic_range if dynamic_range >= 1.0 else 255.0 - + if stretch_full <= STRETCH_CAP: expected_strength = 1.0 else: expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) - - print(f"Low range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") - + + print( + f"Low range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}" + ) + # Stretch should exceed cap, strength should be reduced self.assertGreater(stretch_full, STRETCH_CAP) self.assertLess(expected_strength, 1.0) self.assertGreater(expected_strength, 0.3) # Should still be reasonable - + # Test case 3: Very low dynamic range (120-121, range≈1) # Expected: strength = 0 (degenerate case) arr_flat = np.full((100, 100), 120, dtype=np.uint8) @@ -162,25 +170,27 @@ def test_auto_levels_stretch_capping(self): img_flat = Image.fromarray(arr_flat) self.editor.original_image = img_flat self.editor._preview_image = img_flat - + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - + dynamic_range = p_high - p_low - - print(f"Flat image: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}") - + + print( + f"Flat image: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}" + ) + # For very low range, should be near 0 or exactly 0 self.assertLess(dynamic_range, 3.0) - + def test_auto_levels_clipping_tolerance(self): """ Regression test: Verify that auto-levels respects the threshold setting and doesn't introduce excessive clipping beyond the configured tolerance. - + Uses deterministic synthetic images to verify clipping stays within bounds. """ threshold_percent = 0.1 - + # Create a deterministic image with known distribution # Use a beta distribution to create realistic luminance distribution # Beta(2, 5) gives a left-skewed distribution (more shadows, fewer highlights) @@ -188,54 +198,63 @@ def test_auto_levels_clipping_tolerance(self): beta_samples = np.random.beta(2, 5, size=10000) arr = (beta_samples * 255).astype(np.uint8).reshape(100, 100) img = Image.fromarray(arr) - + self.editor.original_image = img self.editor._preview_image = img - + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - + # Apply at full strength - self.editor.set_edit_param('blacks', blacks) - self.editor.set_edit_param('whites', whites) - result = self.editor._apply_edits(img.convert('RGB')) - result_arr = np.array(result.convert('L')) - + self.editor.set_edit_param("blacks", blacks) + self.editor.set_edit_param("whites", whites) + result = self.editor._apply_edits(img.convert("RGB")) + result_arr = np.array(result.convert("L")) + # Count pixels at extremes total_pixels = result_arr.size clipped_low = np.sum(result_arr == 0) clipped_high = np.sum(result_arr == 255) - + pct_clipped_low = (clipped_low / total_pixels) * 100.0 pct_clipped_high = (clipped_high / total_pixels) * 100.0 - - print(f"Beta distribution: Low clip: {pct_clipped_low:.2f}%, High clip: {pct_clipped_high:.2f}%") - + + print( + f"Beta distribution: Low clip: {pct_clipped_low:.2f}%, High clip: {pct_clipped_high:.2f}%" + ) + # Allow small tolerance for rounding and integer quantization # The threshold defines the percentiles, but due to discrete pixel values # and the mapping, we may end up with slightly different clipping tolerance = 0.5 # 0.1% threshold + 0.5% tolerance = 0.6% max - - self.assertLessEqual(pct_clipped_low, threshold_percent + tolerance, - f"Excessive shadow clipping: {pct_clipped_low:.2f}% > {threshold_percent + tolerance}%") - self.assertLessEqual(pct_clipped_high, threshold_percent + tolerance, - f"Excessive highlight clipping: {pct_clipped_high:.2f}% > {threshold_percent + tolerance}%") - + + self.assertLessEqual( + pct_clipped_low, + threshold_percent + tolerance, + f"Excessive shadow clipping: {pct_clipped_low:.2f}% > {threshold_percent + tolerance}%", + ) + self.assertLessEqual( + pct_clipped_high, + threshold_percent + tolerance, + f"Excessive highlight clipping: {pct_clipped_high:.2f}% > {threshold_percent + tolerance}%", + ) + # Verify mapping is monotonic (sanity check) # Create a gradient and verify it maps monotonically gradient = np.arange(256, dtype=np.uint8) gradient_img = Image.fromarray(gradient.reshape(1, 256)) self.editor.original_image = gradient_img self.editor._preview_image = gradient_img - + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) - self.editor.set_edit_param('blacks', blacks) - self.editor.set_edit_param('whites', whites) - result = self.editor._apply_edits(gradient_img.convert('RGB')) - result_arr = np.array(result.convert('L'))[0, :] - + self.editor.set_edit_param("blacks", blacks) + self.editor.set_edit_param("whites", whites) + result = self.editor._apply_edits(gradient_img.convert("RGB")) + result_arr = np.array(result.convert("L"))[0, :] + # Check monotonicity diffs = np.diff(result_arr.astype(np.int16)) self.assertTrue(np.all(diffs >= 0), "Mapping is not monotonic") -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/faststack/tests/test_pairing.py b/faststack/tests/test_pairing.py index 8a8978e..991544a 100644 --- a/faststack/tests/test_pairing.py +++ b/faststack/tests/test_pairing.py @@ -9,6 +9,7 @@ from faststack.io.indexer import find_images, _find_raw_pair + @pytest.fixture def mock_image_dir(tmp_path: Path): """Creates a temporary directory with mock image files.""" @@ -21,9 +22,9 @@ def mock_image_dir(tmp_path: Path): time.sleep(0.01) # Raws (CR3) - (tmp_path / "IMG_0001.CR3").touch() # Perfect match + (tmp_path / "IMG_0001.CR3").touch() # Perfect match # Match for 0002, but with a slight time diff - two_cr3 = (tmp_path / "IMG_0002.CR3") + two_cr3 = tmp_path / "IMG_0002.CR3" two_cr3.touch() # Change timestamp slightly os.utime(two_cr3, (two_cr3.stat().st_atime, two_cr3.stat().st_mtime + 0.5)) @@ -33,6 +34,7 @@ def mock_image_dir(tmp_path: Path): return tmp_path + def test_find_images(mock_image_dir: Path): """Tests the main find_images function.""" images = find_images(mock_image_dir) @@ -49,24 +51,34 @@ def test_find_images(mock_image_dir: Path): assert images[2].path.name == "IMG_0003.jpeg" assert images[2].raw_pair is None + def test_raw_pairing_logic(): """Unit tests the _find_raw_pair function specifically.""" jpg_path = Path("IMG_01.JPG") - jpg_stat = MagicMock(); jpg_stat.st_mtime = 1000.0 + jpg_stat = MagicMock() + jpg_stat.st_mtime = 1000.0 # Case 1: Perfect match - raw1_path = Path("IMG_01.CR3"); raw1_stat = MagicMock(); raw1_stat.st_mtime = 1000.1 + raw1_path = Path("IMG_01.CR3") + raw1_stat = MagicMock() + raw1_stat.st_mtime = 1000.1 potentials = [(raw1_path, raw1_stat)] assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw1_path # Case 2: No match (time delta too large) - raw2_path = Path("IMG_01.CR3"); raw2_stat = MagicMock(); raw2_stat.st_mtime = 1003.0 + raw2_path = Path("IMG_01.CR3") + raw2_stat = MagicMock() + raw2_stat.st_mtime = 1003.0 potentials = [(raw2_path, raw2_stat)] assert _find_raw_pair(jpg_path, jpg_stat, potentials) is None # Case 3: Closest match is chosen - raw3_path = Path("IMG_01_A.CR3"); raw3_stat = MagicMock(); raw3_stat.st_mtime = 1000.5 - raw4_path = Path("IMG_01_B.CR3"); raw4_stat = MagicMock(); raw4_stat.st_mtime = 1001.8 + raw3_path = Path("IMG_01_A.CR3") + raw3_stat = MagicMock() + raw3_stat.st_mtime = 1000.5 + raw4_path = Path("IMG_01_B.CR3") + raw4_stat = MagicMock() + raw4_stat.st_mtime = 1001.8 potentials = [(raw3_path, raw3_stat), (raw4_path, raw4_stat)] assert _find_raw_pair(jpg_path, jpg_stat, potentials) == raw3_path diff --git a/faststack/tests/test_permanent_delete.py b/faststack/tests/test_permanent_delete.py new file mode 100644 index 0000000..ff4796c --- /dev/null +++ b/faststack/tests/test_permanent_delete.py @@ -0,0 +1,124 @@ +"""Tests for permanent delete logic in faststack.io.deletion.""" + +import pytest +from pathlib import Path +from unittest.mock import Mock, patch + +# Import the standalone module, avoiding heavy app imports +from faststack.io.deletion import ( + ensure_recycle_bin_dir, + confirm_permanent_delete, + permanently_delete_image_files +) + +class MockImageFile: + """Simple mock for ImageFile.""" + def __init__(self, jpg_path: Path, raw_path: Path = None): + self.path = jpg_path + self.raw_pair = raw_path + self.is_video = False + +class TestEnsureRecycleBinDir: + def test_creation_success(self, tmp_path): + """Should return True and create directory when successful.""" + recycle_bin = tmp_path / "RecycleBin" + assert not recycle_bin.exists() + + result = ensure_recycle_bin_dir(recycle_bin) + + assert result is True + assert recycle_bin.exists() + assert recycle_bin.is_dir() + + def test_creation_failure(self, tmp_path): + """Should return False when creation raises PermissionError.""" + recycle_bin = tmp_path / "RecycleBin" + + with patch.object(Path, "mkdir", side_effect=PermissionError("Mock perm error")): + result = ensure_recycle_bin_dir(recycle_bin) + assert result is False + +class TestConfirmPermanentDelete: + def test_confirm_yes(self): + """Should return True when user accepts dialog.""" + mock_img = MockImageFile(Path("test.jpg")) + + with patch("faststack.io.deletion.QMessageBox") as MockMSG: + instance = MockMSG.return_value + instance.exec.return_value = 0 + + mock_delete_btn = Mock(name="DeleteButton") + mock_cancel_btn = Mock(name="CancelButton") + + instance.addButton.side_effect = [mock_delete_btn, mock_cancel_btn] + instance.clickedButton.return_value = mock_delete_btn + + result = confirm_permanent_delete(mock_img) + assert result is True + + def test_confirm_no(self): + """Should return False when user cancels.""" + mock_img = MockImageFile(Path("test.jpg")) + + with patch("faststack.io.deletion.QMessageBox") as MockMSG: + instance = MockMSG.return_value + instance.exec.return_value = 0 + + mock_delete_btn = Mock(name="DeleteButton") + mock_cancel_btn = Mock(name="CancelButton") + + instance.addButton.side_effect = [mock_delete_btn, mock_cancel_btn] + instance.clickedButton.return_value = mock_cancel_btn + + result = confirm_permanent_delete(mock_img) + assert result is False + +class TestPermanentlyDeleteImageFiles: + def test_delete_success(self, tmp_path): + """Should delete files and return True.""" + jpg = tmp_path / "img.jpg" + raw = tmp_path / "img.orf" + jpg.touch() + raw.touch() + + img = MockImageFile(jpg, raw) + + result = permanently_delete_image_files(img) + + assert result is True + assert not jpg.exists() + assert not raw.exists() + + def test_delete_jpg_only(self, tmp_path): + """Should delete JPG if no RAW pair.""" + jpg = tmp_path / "img.jpg" + jpg.touch() + img = MockImageFile(jpg, None) + + result = permanently_delete_image_files(img) + + assert result is True + assert not jpg.exists() + + def test_delete_handles_missing_files(self, tmp_path): + """Should return False if files don't exist.""" + jpg = tmp_path / "missing.jpg" + img = MockImageFile(jpg, None) + + result = permanently_delete_image_files(img) + + assert result is False + + def test_delete_failure_logging(self, tmp_path): + """Should log errors and return False if deletion fails.""" + jpg = tmp_path / "protected.jpg" + jpg.touch() + img = MockImageFile(jpg, None) + + with patch.object(Path, "unlink", side_effect=OSError("Protected")): + with patch("faststack.io.deletion.log") as mock_log: + result = permanently_delete_image_files(img) + + assert result is False + assert jpg.exists() + mock_log.error.assert_called() diff --git a/faststack/tests/test_prefetch_logic.py b/faststack/tests/test_prefetch_logic.py index 3c0f15a..d5004cb 100644 --- a/faststack/tests/test_prefetch_logic.py +++ b/faststack/tests/test_prefetch_logic.py @@ -1,70 +1,73 @@ import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from concurrent.futures import Future import sys # Mock config before importing prefetcher -sys.modules['faststack.config'] = MagicMock() +sys.modules["faststack.config"] = MagicMock() from faststack.imaging.prefetch import Prefetcher + class TestPrefetcher(unittest.TestCase): def test_submit_task_priority_cancellation(self): try: # Mock dependencies mock_cache_put = MagicMock() mock_get_display_info = MagicMock(return_value=(100, 100, 1)) - + # Create dummy image files image_files = [MagicMock() for _ in range(10)] - + prefetcher = Prefetcher( image_files=image_files, cache_put=mock_cache_put, prefetch_radius=4, - get_display_info=mock_get_display_info + get_display_info=mock_get_display_info, ) - + # Mock executor prefetcher.executor = MagicMock() - + # Helper to create a mock future def create_future(): f = MagicMock(spec=Future) f.done.return_value = False f.cancel.return_value = True return f - + # Setup initial state f0 = create_future() f5 = create_future() - + prefetcher.futures[0] = f0 prefetcher.futures[5] = f5 - + print("Submitting task 4...") # Submit priority task for index 4 prefetcher.submit_task(index=4, generation=0, priority=True) print("Task 4 submitted.") - + # Check if task 4 was added if 4 not in prefetcher.futures: raise Exception("Task 4 was not added to futures!") - + # Check cancellation of task 0 (should cancel) print("Checking task 0 cancellation...") f0.cancel.assert_called() print("Task 0 cancelled as expected.") - + # Check cancellation of task 5 (should NOT cancel) print("Checking task 5 cancellation...") f5.cancel.assert_not_called() print("Task 5 NOT cancelled as expected.") - + print("Test passed!") except Exception: import traceback + traceback.print_exc() raise + if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_raw_pipeline.py b/faststack/tests/test_raw_pipeline.py index e9d607f..cb8d35b 100644 --- a/faststack/tests/test_raw_pipeline.py +++ b/faststack/tests/test_raw_pipeline.py @@ -1,6 +1,5 @@ -import os import unittest -from unittest.mock import MagicMock, patch, ANY +from unittest.mock import MagicMock, patch from pathlib import Path import tempfile import shutil @@ -17,23 +16,26 @@ logging.basicConfig(level=logging.DEBUG) log = logging.getLogger(__name__) + class TestRawPipeline(unittest.TestCase): - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') - @patch('faststack.app.threading.Thread') - def test_develop_raw_empty_output_cleanup(self, mock_thread, mock_config_get, mock_run, mock_exists): + @patch("faststack.app.os.path.exists") + @patch("faststack.app.subprocess.run") + @patch("faststack.config.config.get") + @patch("faststack.app.threading.Thread") + def test_develop_raw_empty_output_cleanup( + self, mock_thread, mock_config_get, mock_run, mock_exists + ): """Test garbage collection if RT exits 0 but produces 0-byte file.""" mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" mock_exists.return_value = True # exe exists - + # Make Thread().start() run the target immediately (synchronous for testing) def side_effect_start(*args, **kwargs): _, thread_kwargs = mock_thread.call_args - target = thread_kwargs.get('target') + target = thread_kwargs.get("target") if target: target() - + mock_thread.return_value.start.side_effect = side_effect_start # Mock subprocess.run to return success (returncode=0) @@ -45,43 +47,52 @@ def side_effect_start(*args, **kwargs): app.image_files = [self.image_file] app.current_index = 0 app.update_status_message = MagicMock() - + # Bind the real _develop_raw_backend method to our mock - app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) - + app._develop_raw_backend = AppController._develop_raw_backend.__get__( + app, AppController + ) + # Create 0-byte zombie file BEFORE calling develop tif_path = self.image_file.working_tif_path tif_path.touch() self.assertTrue(tif_path.exists()) self.assertEqual(tif_path.stat().st_size, 0) - + app._develop_raw_backend() - + # Expect file to be DELETED because it was 0 bytes self.assertFalse(tif_path.exists(), "Zombie 0-byte file should be cleaned up") - @patch('faststack.app.QTimer.singleShot') - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') - @patch('faststack.app.threading.Thread') - def test_develop_raw_timeout(self, mock_thread, mock_config_get, mock_run, mock_exists, mock_single_shot): + @patch("faststack.app.QTimer.singleShot") + @patch("faststack.app.os.path.exists") + @patch("faststack.app.subprocess.run") + @patch("faststack.config.config.get") + @patch("faststack.app.threading.Thread") + def test_develop_raw_timeout( + self, mock_thread, mock_config_get, mock_run, mock_exists, mock_single_shot + ): mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" mock_exists.return_value = True def side_effect_start(*args, **kwargs): _, thread_kwargs = mock_thread.call_args - target = thread_kwargs.get('target') + target = thread_kwargs.get("target") if target: target() + mock_thread.return_value.start.side_effect = side_effect_start - - mock_run.side_effect = subprocess.TimeoutExpired(cmd="rawtherapee-cli", timeout=60) + + mock_run.side_effect = subprocess.TimeoutExpired( + cmd="rawtherapee-cli", timeout=60 + ) app = MagicMock() app.image_files = [self.image_file] app.current_index = 0 - app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) + app._develop_raw_backend = AppController._develop_raw_backend.__get__( + app, AppController + ) app._develop_raw_backend() @@ -91,17 +102,20 @@ def side_effect_start(*args, **kwargs): _, callback = mock_single_shot.call_args[0] # callback is functools.partial(self._on_develop_finished, False, err_msg) # For a bound method, callback.func is the method - self.assertTrue(hasattr(callback, 'func')) - self.assertTrue('_on_develop_finished' in str(callback.func)) + self.assertTrue(hasattr(callback, "func")) + self.assertTrue("_on_develop_finished" in str(callback.func)) self.assertEqual(callback.args[0], False) # Success = False self.assertIn("timed out", callback.args[1]) # Msg - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') - @patch('faststack.app.threading.Thread') - def test_develop_raw_with_custom_args(self, mock_thread, mock_config_get, mock_run, mock_exists): + @patch("faststack.app.os.path.exists") + @patch("faststack.app.subprocess.run") + @patch("faststack.config.config.get") + @patch("faststack.app.threading.Thread") + def test_develop_raw_with_custom_args( + self, mock_thread, mock_config_get, mock_run, mock_exists + ): """Test that custom RawTherapee args are correctly passed to the command.""" + # Setup mock behavior for config.get def mock_config_side_effect(section, option): if section == "rawtherapee" and option == "exe": @@ -109,15 +123,17 @@ def mock_config_side_effect(section, option): if section == "rawtherapee" and option == "args": return "-p my_profile.pp3 -s" return None + mock_config_get.side_effect = mock_config_side_effect mock_exists.return_value = True # Run target in thread immediately def side_effect_start(*args, **kwargs): _, thread_kwargs = mock_thread.call_args - target = thread_kwargs.get('target') + target = thread_kwargs.get("target") if target: target() + mock_thread.return_value.start.side_effect = side_effect_start # Mock subprocess.run @@ -128,47 +144,48 @@ def side_effect_start(*args, **kwargs): app = MagicMock() app.image_files = [self.image_file] app.current_index = 0 - app._develop_raw_backend = AppController._develop_raw_backend.__get__(app, AppController) + app._develop_raw_backend = AppController._develop_raw_backend.__get__( + app, AppController + ) app._develop_raw_backend() # Verify command mock_run.assert_called_once() cmd = mock_run.call_args[0][0] - + # Check base command structure self.assertEqual(cmd[0], "c:\\path\\to\\rawtherapee-cli.exe") self.assertIn("-t", cmd) self.assertIn("-b16", cmd) self.assertIn("-Y", cmd) - + # Check custom args self.assertIn("-p", cmd) self.assertIn("my_profile.pp3", cmd) self.assertIn("-s", cmd) - + # Check input/output order (input -c should be after args) self.assertEqual(cmd[-2], "-c") self.assertEqual(cmd[-1], str(self.image_file.raw_path)) - def setUp(self): self.tmp_dir = tempfile.mkdtemp() self.tmp_path = Path(self.tmp_dir) - + # Setup dummy RAW file self.raw_path = self.tmp_path / "test_image.CR2" self.raw_path.touch() - + # Setup dummy JPG for indexer (FastStack usually finds JPGs first) self.jpg_path = self.tmp_path / "test_image.jpg" # Create a real small JPG - img = Image.new('RGB', (100, 100), color='red') + img = Image.new("RGB", (100, 100), color="red") img.save(self.jpg_path) - + self.image_file = ImageFile(path=self.jpg_path) self.image_file.raw_pair = self.raw_path - + def tearDown(self): shutil.rmtree(self.tmp_dir) @@ -176,23 +193,28 @@ def test_image_file_properties(self): """Test computed properties for RAW pipeline.""" self.assertTrue(self.image_file.has_raw) self.assertEqual(self.image_file.raw_path, self.tmp_path / "test_image.CR2") - self.assertEqual(self.image_file.working_tif_path, self.tmp_path / "test_image-working.tif") - self.assertEqual(self.image_file.developed_jpg_path, self.tmp_path / "test_image-developed.jpg") - + self.assertEqual( + self.image_file.working_tif_path, self.tmp_path / "test_image-working.tif" + ) + self.assertEqual( + self.image_file.developed_jpg_path, + self.tmp_path / "test_image-developed.jpg", + ) + # Rename raw to break pairing shutil.move(self.raw_path, self.tmp_path / "other.CR2") img2 = ImageFile(path=self.jpg_path) self.assertFalse(img2.has_raw) - @patch('faststack.app.os.path.exists') - @patch('faststack.app.subprocess.run') - @patch('faststack.config.config.get') + @patch("faststack.app.os.path.exists") + @patch("faststack.app.subprocess.run") + @patch("faststack.config.config.get") def test_develop_raw_slot(self, mock_config_get, mock_run, mock_exists): """Test the develop_raw_for_current_image slot.""" # Mock Config mock_config_get.return_value = "c:\\path\\to\\rawtherapee-cli.exe" - mock_exists.return_value = True # Pretend exe exists - + mock_exists.return_value = True # Pretend exe exists + # Mock AppController partial environment app = MagicMock() app.image_files = [self.image_file] @@ -200,80 +222,82 @@ def test_develop_raw_slot(self, mock_config_get, mock_run, mock_exists): app.update_status_message = MagicMock() app.load_image_for_editing = MagicMock() app.enable_raw_editing = MagicMock() - + # Bind real method func = AppController.develop_raw_for_current_image.__get__(app, AppController) func() - + # Verify delegation app.enable_raw_editing.assert_called_once() def test_editor_float_pipeline_io(self): """Test that editor saves 16-bit TIFF and Developed JPG.""" editor = ImageEditor() - + # Create a dummy 16-bit TIFF - # We simulate this by creating a float array and 'loading' it manually + # We simulate this by creating a float array and 'loading' it manually # because standard PIL won't write our 16-bit TIFF easily for setup. # But we can create the file using our NEW writer! - + tif_path = self.tmp_path / "working-working.tif" - tif_path.touch() # Ensure it exists for backup logic - + tif_path.touch() # Ensure it exists for backup logic + # Create float data arr = np.zeros((50, 50, 3), dtype=np.float32) - arr[:, :, 0] = 1.0 # Red - + arr[:, :, 0] = 1.0 # Red + # Use private writer to create source file (bootstrapping) # Or just use load_image with a JPG and save as TIFF - + # Let's load the JPG as source, but 'fake' the current filepath as TIFF editor.load_image(str(self.jpg_path)) - editor.current_filepath = tif_path # Trick it - + editor.current_filepath = tif_path # Trick it + # Apply edits - editor.current_edits['exposure'] = 1.0 # +1 EV -> 2x gain - + editor.current_edits["exposure"] = 1.0 # +1 EV -> 2x gain + # Save res = editor.save_image(write_developed_jpg=True) self.assertIsNotNone(res) saved_path, backup_path = res - + self.assertEqual(saved_path, tif_path) self.assertTrue(tif_path.exists()) # With "working-working.tif" as current_filepath, the stem is "working-working". # Our new logic strips one "-working", so it becomes "working-developed.jpg". expected_dev_path = self.tmp_path / "working-developed.jpg" - self.assertTrue(expected_dev_path.exists(), f"Expected {expected_dev_path} to exist") - + self.assertTrue( + expected_dev_path.exists(), f"Expected {expected_dev_path} to exist" + ) + # Verify TIFF Content (Basic) - with open(tif_path, 'rb') as f: + with open(tif_path, "rb") as f: header = f.read(4) - self.assertEqual(header, b'II\x2a\x00') # Little endian TIFF - + self.assertEqual(header, b"II\x2a\x00") # Little endian TIFF + # Verify Developed JPG exists self.assertTrue(expected_dev_path.exists()) def test_editor_edit_float_logic(self): """Test float math.""" editor = ImageEditor() - arr = np.ones((10, 10, 3), dtype=np.float32) * 0.5 # Mid gray - + arr = np.ones((10, 10, 3), dtype=np.float32) * 0.5 # Mid gray + # Exposure +1 (2x gain in linear space) # 0.5 sRGB is ~0.214 linear. 2x -> 0.428 linear. 0.428 linear is ~0.6858 sRGB. - edits = {'exposure': 1.0} + edits = {"exposure": 1.0} res = editor._apply_edits(arr.copy(), edits, for_export=True) np.testing.assert_allclose(res, 0.6858, atol=0.01) - + # Exposure -1 (0.5x gain in linear space) # 0.5 sRGB is ~0.214 linear. 0.5x -> 0.107 linear. 0.107 linear is ~0.3617 sRGB. - edits = {'exposure': -1.0} + edits = {"exposure": -1.0} res = editor._apply_edits(arr.copy(), edits, for_export=True) np.testing.assert_allclose(res, 0.3617, atol=0.01) - + # Brightness (sRGB Multiplication) # Brightness 0.5 -> 1.5x gain on sRGB # 0.5 sRGB * 1.5 = 0.75 sRGB. - edits = {'brightness': 0.5} + edits = {"brightness": 0.5} res = editor._apply_edits(arr.copy(), edits, for_export=True) np.testing.assert_allclose(res, 0.75, atol=0.01) diff --git a/faststack/tests/test_reactive_delete.py b/faststack/tests/test_reactive_delete.py new file mode 100644 index 0000000..d3e4f4a --- /dev/null +++ b/faststack/tests/test_reactive_delete.py @@ -0,0 +1,96 @@ +import pytest +from unittest.mock import MagicMock, patch + + +@pytest.fixture +def app_controller(tmp_path): + from PySide6.QtCore import QCoreApplication + from faststack.app import AppController + + # Ensure QCoreApplication exists + app = QCoreApplication.instance() + if not app: + app = QCoreApplication([]) + + image_dir = tmp_path / "images" + image_dir.mkdir() + + mock_engine = MagicMock() + + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.config"), + patch("faststack.app.ThumbnailProvider"), + patch("faststack.app.ThumbnailModel"), + patch("faststack.app.ThumbnailPrefetcher"), + patch("faststack.app.ThumbnailCache"), + ): + controller = AppController(image_dir, mock_engine, debug_cache=False) + return controller + + +def test_reactive_delete_fallback(app_controller, tmp_path): + """Test that delete logic prompts for permanent delete when recycle fails.""" + # Setup + img_path = app_controller.image_dir / "test.jpg" + img_path.write_text("content") + + img_file = MagicMock() + img_file.path = img_path + img_file.raw_pair = None + + app_controller.image_files = [img_file] + + # Mock _move_to_recycle to raise OSError + with patch.object( + app_controller, "_move_to_recycle", side_effect=OSError("Permission denied") + ): + # Mock confirmation dialogs + # First one is "Recycle bin partial failure..." -> say YES + with patch.object( + app_controller, "_confirm_batch_permanent_delete", return_value=True + ) as mock_confirm: + # Mock permanent delete execution + with patch.object( + app_controller, "_permanently_delete_image_files", return_value=True + ) as mock_perm_delete: + app_controller._delete_grid_selected_images([img_path]) + + # Verify fallback triggered + mock_confirm.assert_called_once() + assert ( + "Recycle bin partial failure" in mock_confirm.call_args[1]["reason"] + ) + + # Verify permanent delete called + mock_perm_delete.assert_called_with(img_file) + + +def test_reactive_delete_fallback_cancelled(app_controller, tmp_path): + """Test that user can cancel the fallback permanent delete.""" + img_path = app_controller.image_dir / "test.jpg" + img_path.write_text("content") + + img_file = MagicMock() + img_file.path = img_path + img_file.raw_pair = None + + app_controller.image_files = [img_file] + + with patch.object( + app_controller, "_move_to_recycle", side_effect=OSError("Permission denied") + ): + # User says NO to permanent delete + with patch.object( + app_controller, "_confirm_batch_permanent_delete", return_value=False + ) as mock_confirm: + with patch.object( + app_controller, "_permanently_delete_image_files" + ) as mock_perm_delete: + app_controller._delete_grid_selected_images([img_path]) + + mock_confirm.assert_called_once() + mock_perm_delete.assert_not_called() diff --git a/faststack/tests/test_recycle_bin_tracking.py b/faststack/tests/test_recycle_bin_tracking.py new file mode 100644 index 0000000..9a06f69 --- /dev/null +++ b/faststack/tests/test_recycle_bin_tracking.py @@ -0,0 +1,129 @@ +import pytest +from unittest.mock import MagicMock, patch +from faststack.app import AppController + + +@pytest.fixture +def app_controller(tmp_path): + """Fixture to create an AppController with a temporary image directory.""" + from PySide6.QtCore import QCoreApplication + + # Create QCoreApplication instance if it doesn't exist + app = QCoreApplication.instance() + if not app: + app = QCoreApplication([]) + + # Create a dummy image directory + image_dir = tmp_path / "images" + image_dir.mkdir() + + # Mock engine and other deps + mock_engine = MagicMock() + + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.config"), + patch("faststack.app.ThumbnailProvider"), + patch("faststack.app.ThumbnailModel"), + patch("faststack.app.ThumbnailPrefetcher"), + patch("faststack.app.ThumbnailCache"), + ): + # Initialize controller + controller = AppController(image_dir, mock_engine, debug_cache=False) + return controller + + +def test_active_recycle_bins_initialization(app_controller): + """Test that active_recycle_bins is initialized as an empty set.""" + assert hasattr(app_controller, "active_recycle_bins") + assert isinstance(app_controller.active_recycle_bins, set) + assert len(app_controller.active_recycle_bins) == 0 + + +def test_move_to_recycle_tracks_bin(app_controller, tmp_path): + """Test that moving a file to recycle bin adds the bin path to tracking.""" + # Create a dummy file + src_file = app_controller.image_dir / "test.jpg" + src_file.write_text("dummy content") + + # Move to recycle + recycled_path = app_controller._move_to_recycle(src_file) + + # Verify file was moved + assert recycled_path is not None + assert recycled_path.exists() + assert not src_file.exists() + + # Verify bin is tracked + expected_bin = app_controller.image_dir / "image recycle bin" + assert expected_bin in app_controller.active_recycle_bins + + +def test_get_recycle_bin_stats(app_controller): + """Test that get_recycle_bin_stats returns correct counts.""" + # Create bin manually + recycle_bin = app_controller.image_dir / "image recycle bin" + recycle_bin.mkdir(parents=True) + + # Add items + (recycle_bin / "file1.jpg").touch() + (recycle_bin / "file2.jpg").touch() + + # Track it + app_controller.active_recycle_bins.add(recycle_bin) + + # Get stats + stats = app_controller.get_recycle_bin_stats() + + assert len(stats) == 1 + assert stats[0]["path"] == str(recycle_bin) + assert stats[0]["count"] == 2 + + +def test_cleanup_recycle_bins(app_controller): + """Test that cleanup_recycle_bins removes the folders and clears tracking.""" + # Create bin manually + recycle_bin = app_controller.image_dir / "image recycle bin" + recycle_bin.mkdir(parents=True) + (recycle_bin / "file1.jpg").touch() + + # Track it + app_controller.active_recycle_bins.add(recycle_bin) + + # Cleanup + app_controller.cleanup_recycle_bins() + + # Verify + assert not recycle_bin.exists() + assert len(app_controller.active_recycle_bins) == 0 + + +def test_get_recycle_bin_stats_empty_bin(app_controller): + """Test that empty bins are excluded or return 0 count.""" + # Create empty bin + recycle_bin = app_controller.image_dir / "image recycle bin" + recycle_bin.mkdir(parents=True) + + app_controller.active_recycle_bins.add(recycle_bin) + + stats = app_controller.get_recycle_bin_stats() + + # Depending on implementation, it might append with 0 or skip. + # Current implementation: if count > 0: stats.append(...) + assert len(stats) == 0 + + +def test_cleanup_handles_missing_bin(app_controller): + """Test that cleanup handles bins that were already deleted externally.""" + recycle_bin = app_controller.image_dir / "image recycle bin" + + # Add to tracking but don't create it (or delete it) + app_controller.active_recycle_bins.add(recycle_bin) + + # Cleanup should not raise error + app_controller.cleanup_recycle_bins() + + assert len(app_controller.active_recycle_bins) == 0 diff --git a/faststack/tests/test_rolloff.py b/faststack/tests/test_rolloff.py index 37d8eca..e8ce307 100644 --- a/faststack/tests/test_rolloff.py +++ b/faststack/tests/test_rolloff.py @@ -1,17 +1,17 @@ import unittest -from unittest.mock import MagicMock, patch import numpy as np -import sys # We check for modules that might be missing and mock them if needed # inside the test setup to avoid import errors at module level. + class TestRolloff(unittest.TestCase): def setUp(self): # Now we use the pure math utils, so no need to mock cv2/gui/models # unless math_utils unexpectedly depends on them. - + from faststack.imaging.math_utils import _apply_headroom_shoulder + self._apply_headroom_shoulder = _apply_headroom_shoulder def tearDown(self): @@ -30,13 +30,13 @@ def test_apply_headroom_shoulder_rolloff(self): # 1.0 + max_overshoot is the asymptote x = np.array([1.01, 1.1, 2.0, 10.0]) out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - + # Check that they are compressed (out < x) self.assertTrue(np.all(out < x)) - + # Check that they stay above 1.0 self.assertTrue(np.all(out > 1.0)) - + # Check asymptote (should never exceed 1.0 + max_overshoot) self.assertTrue(np.all(out < 1.0 + max_overshoot)) @@ -45,7 +45,7 @@ def test_apply_headroom_shoulder_monotonic(self): max_overshoot = 0.05 x = np.linspace(0.9, 5.0, 100) out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - + # Check if strictly increasing diffs = np.diff(out) self.assertTrue(np.all(diffs > 0), "Output should be monotonic increasing") @@ -56,26 +56,27 @@ def test_apply_headroom_shoulder_continuity(self): # Check very close to 1.0 from both sides x = np.array([1.0 - 1e-7, 1.0, 1.0 + 1e-7]) out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - + # Difference should be negligible diffs = np.diff(out) # Should be very small but positive self.assertTrue(np.all(np.abs(diffs) < 1e-6)) - + def test_apply_headroom_shoulder_asymptote_check(self): # Verification Plan Step: Feed synthetic array with very high values max_overshoot = 0.05 - x = np.array([1.0, 1.0 + max_overshoot/2, 1.0 + 1000.0]) + x = np.array([1.0, 1.0 + max_overshoot / 2, 1.0 + 1000.0]) out = self._apply_headroom_shoulder(x, max_overshoot=max_overshoot) - + # f(1.0) == 1.0 self.assertAlmostEqual(out[0], 1.0) - + # f(very_large) should be close to 1.0 + max_overshoot self.assertAlmostEqual(out[2], 1.0 + max_overshoot, places=4) - + # values <= 1.0 + max_overshoot self.assertTrue(np.all(out <= 1.0 + max_overshoot + 1e-9)) + if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/test_rotation_unittest.py b/faststack/tests/test_rotation_unittest.py index 00d2336..dc7da15 100644 --- a/faststack/tests/test_rotation_unittest.py +++ b/faststack/tests/test_rotation_unittest.py @@ -1,8 +1,8 @@ import unittest import numpy as np -from PIL import Image from faststack.imaging.editor import ImageEditor + class TestEditorRotation(unittest.TestCase): def setUp(self): self.editor = ImageEditor() @@ -15,7 +15,7 @@ def create_quadrant_image_float(self, w=100, h=100): # BR: White (1, 1, 1) arr = np.zeros((h, w, 3), dtype=np.float32) cx, cy = w // 2, h // 2 - + # TL arr[:cy, :cx] = [1, 0, 0] # TR @@ -24,69 +24,75 @@ def create_quadrant_image_float(self, w=100, h=100): arr[cy:, :cx] = [0, 0, 1] # BR arr[cy:, cx:] = [1, 1, 1] - + return arr def test_rotate_cw(self): """Test CW rotation (90 deg clockwise).""" # Logic: (current - 90). np.rot90 k=1 is CCW. # CW = -90 = 270 CCW. k=3. - + arr = self.create_quadrant_image_float() - + # Manually set rotation to 270 (which is -90 CW) - self.editor.current_edits['rotation'] = 270 - + self.editor.current_edits["rotation"] = 270 + # Apply res = self.editor._apply_edits(arr.copy(), for_export=True) - + # Check Quadrants # TL (Red) -> TR # TR (Green) -> BR # BL (Blue) -> TL # BR (White) -> BL - + w, h = 100, 100 qw, qh = 25, 25 - + # New TL (was BL Blue) np.testing.assert_allclose(res[qh, qw], [0, 0, 1], err_msg="TL should be Blue") # New TR (was TL Red) - np.testing.assert_allclose(res[qh, w-qw], [1, 0, 0], err_msg="TR should be Red") + np.testing.assert_allclose( + res[qh, w - qw], [1, 0, 0], err_msg="TR should be Red" + ) # New BL (was BR White) - np.testing.assert_allclose(res[h-qh, qw], [1, 1, 1], err_msg="BL should be White") + np.testing.assert_allclose( + res[h - qh, qw], [1, 1, 1], err_msg="BL should be White" + ) # New BR (was TR Green) - np.testing.assert_allclose(res[h-qh, w-qw], [0, 1, 0], err_msg="BR should be Green") + np.testing.assert_allclose( + res[h - qh, w - qw], [0, 1, 0], err_msg="BR should be Green" + ) def test_straighten_angle(self): """Test free rotation.""" arr = np.zeros((100, 100, 3), dtype=np.float32) # Draw a horizontal line in middle - arr[48:52, :, :] = 1.0 - + arr[48:52, :, :] = 1.0 + # Rotate 90 degrees via straighten - self.editor.current_edits['straighten_angle'] = 90.0 + self.editor.current_edits["straighten_angle"] = 90.0 # Should result in vertical line - - # Note: _rotate_float_image uses PIL rotate. + + # Note: _rotate_float_image uses PIL rotate. # PIL rotate(angle) is Counter-Clockwise. # straighten_angle=90 -> call rotate(-90) -> Clockwise 90? # My implementation: `self._rotate_float_image(arr, -straighten_angle, expand=True)` # If straighten_angle is 90, we call rotate(-90). # rotate(-90) is Clockwise 90. # So horizontal line becomes vertical. - + res = self.editor._apply_edits(arr.copy(), for_export=True) - + # Check shape (expanded) # If expanded, and 90 deg, size should swap (but here 100x100 -> 100x100) self.assertEqual(res.shape[0], 100) self.assertEqual(res.shape[1], 100) - + # Check center column is white-ish (due to bicubic interpolation might be fuzzy) # mid x = 50. center_col = res[:, 50, 0] - self.assertTrue(np.mean(center_col) > 0.1) # Should have signal - + self.assertTrue(np.mean(center_col) > 0.1) # Should have signal + # Check left/right columns are black self.assertTrue(np.mean(res[:, 10, 0]) < 0.1) diff --git a/faststack/tests/test_sensitivity.py b/faststack/tests/test_sensitivity.py index e6431f4..8096fd1 100644 --- a/faststack/tests/test_sensitivity.py +++ b/faststack/tests/test_sensitivity.py @@ -7,6 +7,7 @@ from faststack.imaging.editor import ImageEditor + def test_contrast_saturation_sensitivity(): print("Testing contrast and saturation sensitivity...") editor = ImageEditor() @@ -15,26 +16,26 @@ def test_contrast_saturation_sensitivity(): arr[:, :50, 0] = 0.8 # Red left half arr[:, 50:, 1] = 0.8 # Green right half editor.float_preview = arr - + # Test Contrast at 100 (backend value 1.0) print("Testing Contrast at 1.0...") edits = editor._initial_edits() - edits['contrast'] = 1.0 + edits["contrast"] = 1.0 out = editor._apply_edits(arr.copy(), edits=edits) - + # Original contrast factor was 1.0 + 1.0 = 2.0 # New contrast factor should be 1.0 + 1.0 * 0.4 = 1.4 # Check a pixel that was 0.8: (0.8 - 0.5) * 1.4 + 0.5 = 0.3 * 1.4 + 0.5 = 0.42 + 0.5 = 0.92 val = out[0, 0, 0] print(f"Contrast 1.0 result: {val}") assert np.allclose(val, 0.92, atol=0.01), f"Expected 0.92, got {val}" - + # Test Saturation at 100 (backend value 1.0) print("Testing Saturation at 1.0...") edits = editor._initial_edits() - edits['saturation'] = 1.0 + edits["saturation"] = 1.0 out = editor._apply_edits(arr.copy(), edits=edits) - + # Original saturation factor was 1.0 + 1.0 = 2.0 # New saturation factor should be 1.0 + 1.0 * 0.5 = 1.5 # Pixel (0.8, 0, 0): gray = 0.8 * 0.299 = 0.2392 @@ -44,6 +45,7 @@ def test_contrast_saturation_sensitivity(): assert np.allclose(val_sat, 1.0804, atol=0.01), f"Expected 1.0804, got {val_sat}" print("All tests passed!") + if __name__ == "__main__": try: test_contrast_saturation_sensitivity() diff --git a/faststack/tests/test_sidecar.py b/faststack/tests/test_sidecar.py index e38b964..1e2c344 100644 --- a/faststack/tests/test_sidecar.py +++ b/faststack/tests/test_sidecar.py @@ -8,15 +8,19 @@ from faststack.io.sidecar import SidecarManager from faststack.models import EntryMetadata + @pytest.fixture def mock_sidecar_dir(tmp_path: Path): """Creates a temp dir and can pre-populate a sidecar file.""" + def _create(content: dict = None): if content: (tmp_path / "faststack.json").write_text(json.dumps(content)) return tmp_path + return _create + def test_sidecar_load_non_existent(mock_sidecar_dir): """Tests loading when no sidecar file exists.""" d = mock_sidecar_dir() @@ -25,29 +29,31 @@ def test_sidecar_load_non_existent(mock_sidecar_dir): assert sm.data.last_index == 0 assert not sm.data.entries + def test_sidecar_load_existing(mock_sidecar_dir): """Tests loading a valid, existing sidecar file.""" content = { "version": 2, "last_index": 42, "entries": { - "IMG_0001": { "flag": True, "reject": False, "stack_id": 1 }, - "IMG_0002": { "flag": False, "reject": True, "stack_id": None }, - } + "IMG_0001": {"flag": True, "reject": False, "stack_id": 1}, + "IMG_0002": {"flag": False, "reject": True, "stack_id": None}, + }, } d = mock_sidecar_dir(content) sm = SidecarManager(d, None) assert sm.data.last_index == 42 assert len(sm.data.entries) == 2 - + # flag and reject are legacy and not in current model, so they are dropped. # stack_id IS in the current model, so it should be preserved. assert sm.data.entries["IMG_0001"].stack_id == 1 - + # IMG_0002 has stack_id=None assert sm.data.entries["IMG_0002"].stack_id is None + def test_sidecar_save(mock_sidecar_dir): """Tests saving data back to the JSON file.""" d = mock_sidecar_dir() @@ -58,7 +64,7 @@ def test_sidecar_save(mock_sidecar_dir): meta = sm.get_metadata("IMG_TEST") # Modify a valid field meta.stack_id = 99 - + # Save sm.save() @@ -67,6 +73,7 @@ def test_sidecar_save(mock_sidecar_dir): assert saved_data["last_index"] == 10 assert saved_data["entries"]["IMG_TEST"]["stack_id"] == 99 + def test_sidecar_get_metadata_creates_new(mock_sidecar_dir): """Tests that get_metadata creates a new entry if one doesn't exist.""" d = mock_sidecar_dir() diff --git a/faststack/tests/test_undo_refactor.py b/faststack/tests/test_undo_refactor.py new file mode 100644 index 0000000..a80e1cc --- /dev/null +++ b/faststack/tests/test_undo_refactor.py @@ -0,0 +1,210 @@ +import pytest +from unittest.mock import MagicMock, patch +import shutil + + +# Create a dummy fixture for AppController that uses the real class but mocks dependencies +@pytest.fixture +def app_controller(tmp_path): + from PySide6.QtCore import QCoreApplication + from faststack.app import AppController + + # Ensure QCoreApplication exists + app = QCoreApplication.instance() + if not app: + app = QCoreApplication([]) + + image_dir = tmp_path / "images" + image_dir.mkdir() + + mock_engine = MagicMock() + + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.config"), + patch("faststack.app.ThumbnailProvider"), + patch("faststack.app.ThumbnailModel"), + patch("faststack.app.ThumbnailPrefetcher"), + patch("faststack.app.ThumbnailCache"), + ): + controller = AppController(image_dir, mock_engine, debug_cache=False) + # Mock UI state + controller.ui_state = MagicMock() + controller.ui_state.isHistogramVisible = False + + return controller + + +class TestRestoreFromRecycleBinSafe: + def test_restore_success(self, app_controller, tmp_path): + """Test successful restore.""" + bin_file = tmp_path / "bin.jpg" + bin_file.write_text("data") + dest_file = tmp_path / "restored.jpg" + + success, reason = app_controller._restore_from_recycle_bin_safe( + dest_file, bin_file + ) + + assert success is True + assert reason == "ok" + assert dest_file.exists() + assert not bin_file.exists() + + def test_missing_bin_file(self, app_controller, tmp_path): + """Test restore fails if bin file is missing.""" + bin_file = tmp_path / "missing.jpg" + dest_file = tmp_path / "restored.jpg" + + success, reason = app_controller._restore_from_recycle_bin_safe( + dest_file, bin_file + ) + + assert success is False + assert reason == "missing_in_bin" + assert not dest_file.exists() + + def test_dest_exists(self, app_controller, tmp_path): + """Test restore fails if destination already exists.""" + bin_file = tmp_path / "bin.jpg" + bin_file.write_text("bin data") + dest_file = tmp_path / "existing.jpg" + dest_file.write_text("existing data") + + success, reason = app_controller._restore_from_recycle_bin_safe( + dest_file, bin_file + ) + + assert success is False + assert reason == "dest_exists" + assert bin_file.exists() # Should not touch bin file + + def test_permission_error(self, app_controller, tmp_path): + """Test handling of OSError during move.""" + bin_file = tmp_path / "bin.jpg" + bin_file.write_text("data") + dest_file = tmp_path / "restored.jpg" + + with patch("shutil.move", side_effect=OSError("Permission denied")): + success, reason = app_controller._restore_from_recycle_bin_safe( + dest_file, bin_file + ) + + assert success is False + assert reason == "move_failed" + assert bin_file.exists() + + +class TestUndoDeleteAtomicity: + def test_undo_delete_success(self, app_controller, tmp_path): + """Test undo delete successfully restores both JPG and RAW.""" + # Setup paths + jpg_src = tmp_path / "img.jpg" + jpg_bin = tmp_path / "bin_img.jpg" + raw_src = tmp_path / "img.orf" + raw_bin = tmp_path / "bin_img.orf" + + jpg_bin.write_text("jpg") + raw_bin.write_text("raw") + + # Setup history + action_data = ((jpg_src, jpg_bin), (raw_src, raw_bin)) + app_controller.undo_history.append(("delete", action_data, 12345)) + app_controller.delete_history.append(action_data) + + # Mock refresh methods to avoid complex logic + app_controller._post_undo_refresh_and_select = MagicMock() + + # Execute + app_controller.undo_delete() + + # Verify + assert jpg_src.exists() + assert raw_src.exists() + assert not jpg_bin.exists() + assert not raw_bin.exists() + # History should be empty + assert len(app_controller.delete_history) == 0 + assert len(app_controller.undo_history) == 0 + + def test_undo_delete_raw_exists_strategy(self, app_controller, tmp_path): + """Test that if RAW exists, JPG is kept (no rollback) and user is warned.""" + jpg_src = tmp_path / "img.jpg" + jpg_bin = tmp_path / "bin_img.jpg" + raw_src = tmp_path / "img.orf" + raw_bin = tmp_path / "bin_img.orf" + + jpg_bin.write_text("jpg") + raw_bin.write_text("raw") + raw_src.write_text("existing raw") # RAW already exists + + action_data = ((jpg_src, jpg_bin), (raw_src, raw_bin)) + app_controller.undo_history.append(("delete", action_data, 12345)) + app_controller.delete_history.append(action_data) + + app_controller._post_undo_refresh_and_select = MagicMock() + + app_controller.undo_delete() + + # Verify JPG restored + assert jpg_src.exists() + # Verify RAW still exists (wasn't overwritten, wasn't moved from bin) + assert raw_src.read_text() == "existing raw" + assert raw_bin.exists() # Bin copy remains + + # History cleared because it was considered a partial success + assert len(app_controller.delete_history) == 0 + assert len(app_controller.undo_history) == 0 + + def test_undo_delete_raw_move_fails_rollback(self, app_controller, tmp_path): + """Test that if RAW move fails (OSError), JPG is rolled back.""" + jpg_src = tmp_path / "img.jpg" + jpg_bin = tmp_path / "bin_img.jpg" + raw_src = tmp_path / "img.orf" + raw_bin = tmp_path / "bin_img.orf" + + jpg_bin.write_text("jpg") + raw_bin.write_text("raw") + + action_data = ((jpg_src, jpg_bin), (raw_src, raw_bin)) + app_controller.undo_history.append(("delete", action_data, 12345)) + app_controller.delete_history.append(action_data) + + # Mock shutil.move to fail ONLY for raw + original_move = shutil.move + + def side_effect(src, dst): + if str(src) == str(raw_bin): + raise OSError("Simulated failure") + return original_move(src, dst) + + with patch("shutil.move", side_effect=side_effect): + app_controller.undo_delete() + + # Verify JPG rolled back (exists in bin, not src) + assert not jpg_src.exists() + assert jpg_bin.exists() + + # Verify RAW failed + assert raw_bin.exists() + assert not raw_src.exists() + + # Verify history restored (nothing popped permanently) + assert len(app_controller.delete_history) == 1 + assert len(app_controller.undo_history) == 1 + + +class TestHistoryConsistency: + def test_malformed_history_ignored(self, app_controller): + """Test that malformed history actions don't crash the app.""" + # Action data with missing tuple elements + malformed_data = ("just one thing",) + app_controller.undo_history.append(("delete", malformed_data, 12345)) + + app_controller.undo_delete() + + # Should handle exception and return + assert len(app_controller.undo_history) == 0 # Popped but failed diff --git a/faststack/tests/test_version_sort.py b/faststack/tests/test_version_sort.py index 09deb47..e57b72a 100644 --- a/faststack/tests/test_version_sort.py +++ b/faststack/tests/test_version_sort.py @@ -2,11 +2,12 @@ import re from pathlib import PureWindowsPath + # Re-implementing the function locally to match the fix in config.py # (The function in config.py is nested inside detect_rawtherapee_path so not easily importable) def version_sort_key(path): for part in reversed(PureWindowsPath(path).parts): - if re.fullmatch(r'\d+(?:\.\d+)*', part): + if re.fullmatch(r"\d+(?:\.\d+)*", part): return [int(n) for n in part.split(".")] return [0] @@ -18,19 +19,19 @@ def test_version_sort_preference(self): """ # Scenario: 5.10 (x86) vs 5.9 (x64) # The x86 path has "86" in "Program Files (x86)", which should NOT confuse the sort. - + path_5_10_x86 = r"C:\Program Files (x86)\RawTherapee\5.10\rawtherapee-cli.exe" path_5_9_x64 = r"C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe" - + paths = [path_5_9_x64, path_5_10_x86] - + # BROKEN behavior check (optional logic, just to demonstrate the issue) # In natural sort, "Program Files (x86)" might come after "Program Files" depending on how it handles " (" vs "" - # But specifically, the "86" is a number. - # "Program Files" split -> ['Program Files'] + # But specifically, the "86" is a number. + # "Program Files" split -> ['Program Files'] # "Program Files (x86)" split -> ['Program Files (x', 86, ')'] # Comparison logic is complex but often fails here. - + # CORRECT behavior check paths.sort(key=version_sort_key, reverse=True) self.assertEqual(paths[0], path_5_10_x86, "Should select 5.10 over 5.9") @@ -42,10 +43,10 @@ def test_same_version_different_arch(self): """ p1 = r"C:\Program Files\RawTherapee\5.9\rawtherapee-cli.exe" p2 = r"C:\Program Files (x86)\RawTherapee\5.9\rawtherapee-cli.exe" - + key1 = version_sort_key(p1) key2 = version_sort_key(p2) - + self.assertEqual(key1, [5, 9]) self.assertEqual(key2, [5, 9]) self.assertEqual(key1, key2) @@ -54,5 +55,6 @@ def test_no_version_in_path(self): p = r"C:\Program Files\RawTherapee\bin\rawtherapee-cli.exe" self.assertEqual(version_sort_key(p), [0]) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/faststack/tests/thumbnail_view/test_folder_stats.py b/faststack/tests/thumbnail_view/test_folder_stats.py index 59bdc0b..088ee90 100644 --- a/faststack/tests/thumbnail_view/test_folder_stats.py +++ b/faststack/tests/thumbnail_view/test_folder_stats.py @@ -2,12 +2,16 @@ import json import pytest -from pathlib import Path from faststack.thumbnail_view.folder_stats import ( FolderStats, read_folder_stats, clear_stats_cache, + clear_raw_count_cache, _stats_cache, + _scan_folder_files, + _compute_coverage_buckets, + count_images_in_folder, + get_file_counts_by_extension, ) @@ -54,7 +58,7 @@ def test_read_valid_faststack_json(self, temp_folder): "IMG_001": {"stacked": True, "uploaded": False, "edited": False}, "IMG_002": {"stacked": False, "uploaded": True, "edited": True}, "IMG_003": {"stacked": True, "uploaded": True, "edited": False}, - } + }, } json_path.write_text(json.dumps(data)) @@ -140,6 +144,7 @@ def test_cache_invalidation_on_mtime_change(self, temp_folder): # Modify file with explicit mtime change import os + data["entries"]["IMG_002"] = {"stacked": True} json_path.write_text(json.dumps(data)) # Set mtime to future to ensure cache invalidation @@ -168,7 +173,7 @@ def test_entry_with_non_dict_value(self, temp_folder): "entries": { "IMG_001": {"stacked": True}, "IMG_002": "invalid", # Should be skipped - } + }, } json_path.write_text(json.dumps(data)) @@ -177,3 +182,251 @@ def test_entry_with_non_dict_value(self, temp_folder): assert stats is not None assert stats.total_images == 2 # Both entries counted assert stats.stacked_count == 1 # Only valid entry counted + + +class TestScanFolderFiles: + """Tests for _scan_folder_files function.""" + + def test_scan_empty_folder(self, temp_folder): + """Test scanning an empty folder.""" + jpg_count, raw_count, jpg_files = _scan_folder_files(temp_folder) + assert jpg_count == 0 + assert raw_count == 0 + assert jpg_files == [] + + def test_scan_folder_with_jpgs(self, temp_folder): + """Test scanning a folder with JPG files.""" + (temp_folder / "a.jpg").touch() + (temp_folder / "b.jpeg").touch() + (temp_folder / "c.png").touch() + + jpg_count, raw_count, jpg_files = _scan_folder_files(temp_folder) + + assert jpg_count == 3 + assert raw_count == 0 + assert sorted(jpg_files) == ["a.jpg", "b.jpeg", "c.png"] + + def test_scan_folder_with_raws(self, temp_folder): + """Test scanning a folder with RAW files.""" + (temp_folder / "photo.orf").touch() + (temp_folder / "photo.cr2").touch() + (temp_folder / "photo.nef").touch() + + jpg_count, raw_count, jpg_files = _scan_folder_files(temp_folder) + + assert jpg_count == 0 + assert raw_count == 3 + assert jpg_files == [] + + def test_scan_folder_mixed(self, temp_folder): + """Test scanning a folder with both JPG and RAW files.""" + (temp_folder / "IMG_001.jpg").touch() + (temp_folder / "IMG_001.orf").touch() + (temp_folder / "IMG_002.jpg").touch() + (temp_folder / "IMG_002.orf").touch() + + jpg_count, raw_count, jpg_files = _scan_folder_files(temp_folder) + + assert jpg_count == 2 + assert raw_count == 2 + assert sorted(jpg_files) == ["IMG_001.jpg", "IMG_002.jpg"] + + def test_scan_folder_case_insensitive(self, temp_folder): + """Test that extensions are matched case-insensitively.""" + (temp_folder / "photo.JPG").touch() + (temp_folder / "photo.Jpeg").touch() + (temp_folder / "photo.ORF").touch() + + jpg_count, raw_count, jpg_files = _scan_folder_files(temp_folder) + + assert jpg_count == 2 + assert raw_count == 1 + + def test_scan_folder_sorted_output(self, temp_folder): + """Test that JPG files are sorted alphabetically.""" + (temp_folder / "zebra.jpg").touch() + (temp_folder / "apple.jpg").touch() + (temp_folder / "Banana.jpg").touch() + + _, _, jpg_files = _scan_folder_files(temp_folder) + + # Should be case-insensitive sorted + assert jpg_files == ["apple.jpg", "Banana.jpg", "zebra.jpg"] + + +class TestComputeCoverageBuckets: + """Tests for _compute_coverage_buckets function.""" + + def test_empty_files(self): + """Test with no files.""" + buckets = _compute_coverage_buckets([], {}) + assert buckets == [] + + def test_single_file_uploaded(self): + """Test with single uploaded file.""" + jpg_files = ["a.jpg"] + entries = {"a": {"uploaded": True, "stacked": False}} + + buckets = _compute_coverage_buckets(jpg_files, entries, num_buckets=1) + + assert len(buckets) == 1 + assert buckets[0] == (1.0, 0.0) # uploaded, not stacked + + def test_single_file_stacked(self): + """Test with single stacked file.""" + jpg_files = ["a.jpg"] + entries = {"a": {"uploaded": False, "stacked": True}} + + buckets = _compute_coverage_buckets(jpg_files, entries, num_buckets=1) + + assert len(buckets) == 1 + assert buckets[0] == (0.0, 1.0) # not uploaded, stacked + + def test_even_distribution(self): + """Test even distribution across buckets.""" + jpg_files = ["a.jpg", "b.jpg", "c.jpg", "d.jpg"] + entries = { + "a": {"uploaded": True}, + "b": {"uploaded": True}, + "c": {"uploaded": False}, + "d": {"uploaded": False}, + } + + buckets = _compute_coverage_buckets(jpg_files, entries, num_buckets=2) + + assert len(buckets) == 2 + # First bucket: a, b (both uploaded) + assert buckets[0][0] == 1.0 + # Second bucket: c, d (neither uploaded) + assert buckets[1][0] == 0.0 + + def test_more_buckets_than_files(self): + """Test when num_buckets > num_files.""" + jpg_files = ["a.jpg", "b.jpg"] + entries = {"a": {"uploaded": True}, "b": {"uploaded": False}} + + buckets = _compute_coverage_buckets(jpg_files, entries, num_buckets=10) + + # Should reduce to 2 buckets (one per file) + assert len(buckets) == 2 + + def test_missing_entries(self): + """Test files not in entries dict.""" + jpg_files = ["a.jpg", "b.jpg"] + entries = {"a": {"uploaded": True}} # b is missing + + buckets = _compute_coverage_buckets(jpg_files, entries, num_buckets=2) + + assert len(buckets) == 2 + assert buckets[0][0] == 1.0 # a: uploaded + assert buckets[1][0] == 0.0 # b: not in entries, defaults to False + + def test_coverage_buckets_in_stats(self, temp_folder): + """Test that coverage_buckets is populated in FolderStats.""" + # Create JPG files + (temp_folder / "a.jpg").touch() + (temp_folder / "b.jpg").touch() + + # Create faststack.json with metadata + json_path = temp_folder / "faststack.json" + data = { + "entries": { + "a": {"uploaded": True, "stacked": False}, + "b": {"uploaded": False, "stacked": True}, + } + } + json_path.write_text(json.dumps(data)) + + stats = read_folder_stats(temp_folder) + + assert stats is not None + assert len(stats.coverage_buckets) > 0 + # With 2 files and default 40 buckets, should have 2 buckets + assert len(stats.coverage_buckets) == 2 + + +class TestCountImagesInFolder: + """Tests for count_images_in_folder function.""" + + def test_count_empty_folder(self, temp_folder): + """Test counting images in empty folder.""" + clear_raw_count_cache() + stats = count_images_in_folder(temp_folder) + assert stats is None # No images + + def test_count_folder_with_images(self, temp_folder): + """Test counting images in folder with files.""" + (temp_folder / "photo1.jpg").touch() + (temp_folder / "photo2.orf").touch() + + clear_raw_count_cache() + stats = count_images_in_folder(temp_folder) + + assert stats is not None + assert stats.total_images == 2 + assert stats.jpg_count == 1 + assert stats.raw_count == 1 + # No faststack.json, so these should be 0 + assert stats.stacked_count == 0 + assert stats.uploaded_count == 0 + + +class TestGetFileCountsByExtension: + """Tests for get_file_counts_by_extension function.""" + + def test_empty_folder(self, temp_folder): + """Test counting in empty folder.""" + counts = get_file_counts_by_extension(temp_folder) + assert counts == {} + + def test_count_by_extension(self, temp_folder): + """Test counting files by extension (image extensions roll up to IMG).""" + (temp_folder / "a.jpg").touch() + (temp_folder / "b.jpg").touch() + (temp_folder / "c.orf").touch() + (temp_folder / "d.txt").touch() + + counts = get_file_counts_by_extension(temp_folder) + + assert counts["IMG"] == 2 # .jpg files roll up to IMG + assert counts["ORF"] == 1 + assert counts["TXT"] == 1 + + def test_jpg_extensions_rollup_to_img(self, temp_folder): + """Test that .jpg, .jpeg, .png, and other image extensions all roll up to IMG.""" + (temp_folder / "a.jpg").touch() + (temp_folder / "b.jpeg").touch() + (temp_folder / "c.png").touch() + (temp_folder / "d.gif").touch() + (temp_folder / "e.tiff").touch() + (temp_folder / "f.webp").touch() + (temp_folder / "g.orf").touch() # RAW - not rolled up + + counts = get_file_counts_by_extension(temp_folder) + + assert counts["IMG"] == 6 # All image extensions grouped as IMG + assert counts["ORF"] == 1 # RAW extension kept as-is + assert "JPG" not in counts # JPG should not appear separately + assert "JPEG" not in counts + assert "PNG" not in counts + + def test_excludes_faststack_json(self, temp_folder): + """Test that faststack.json is excluded from counts.""" + (temp_folder / "a.jpg").touch() + (temp_folder / "faststack.json").touch() + + counts = get_file_counts_by_extension(temp_folder) + + assert "JSON" not in counts + assert counts.get("IMG") == 1 + + def test_handles_no_extension(self, temp_folder): + """Test files without extension are not counted.""" + (temp_folder / "README").touch() + (temp_folder / "a.jpg").touch() + + counts = get_file_counts_by_extension(temp_folder) + + # Files without extension should not be in counts + assert "" not in counts + assert counts.get("IMG") == 1 diff --git a/faststack/tests/thumbnail_view/test_model.py b/faststack/tests/thumbnail_view/test_model.py index 3e189da..aa5edf0 100644 --- a/faststack/tests/thumbnail_view/test_model.py +++ b/faststack/tests/thumbnail_view/test_model.py @@ -1,11 +1,16 @@ """Tests for ThumbnailModel.""" +import sys import pytest from pathlib import Path -from unittest.mock import MagicMock, patch -from PySide6.QtCore import Qt +from unittest.mock import patch -from faststack.thumbnail_view.model import ThumbnailModel, ThumbnailEntry, _compute_path_hash +from faststack.thumbnail_view.model import ( + ThumbnailModel, + ThumbnailEntry, + _compute_path_hash, + _is_filesystem_root, +) @pytest.fixture @@ -82,7 +87,7 @@ def test_model_creation(self, model, temp_folder): assert model.base_directory == temp_folder.resolve() assert model.rowCount() == 0 # Not refreshed yet - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_refresh_populates_entries(self, mock_find_images, model, temp_folder): """Test that refresh populates the model.""" from faststack.models import ImageFile @@ -98,7 +103,7 @@ def test_refresh_populates_entries(self, mock_find_images, model, temp_folder): # Should have 1 folder + 2 images (no parent folder since at base) assert model.rowCount() >= 2 - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_folders_sorted_first(self, mock_find_images, model, temp_folder): """Test that folders appear before images.""" from faststack.models import ImageFile @@ -132,7 +137,7 @@ def test_role_names(self, model): assert b"thumbnailSource" in roles.values() assert b"isSelected" in roles.values() - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_parent_folder_at_subdirectory(self, mock_find_images, temp_folder): """Test that parent folder entry appears when not at base.""" from faststack.models import ImageFile @@ -158,26 +163,32 @@ def test_parent_folder_at_subdirectory(self, mock_find_images, temp_folder): assert first_entry.name == ".." assert first_entry.is_folder is True - @patch('faststack.thumbnail_view.model.find_images') - def test_no_parent_folder_at_base(self, mock_find_images, model): - """Test that no parent folder entry when at base directory.""" - from faststack.models import ImageFile + @patch("faststack.thumbnail_view.model.find_images") + def test_parent_folder_shown_when_not_at_root(self, mock_find_images, model): + r"""Test that parent folder entry is shown when not at filesystem root. + + The new behavior allows navigating up even from the initial launch + directory. ".." is only hidden at filesystem roots (/, C:\, etc). + """ mock_find_images.return_value = [] model.refresh() - # No ".." entry when at base - for i in range(model.rowCount()): - entry = model.get_entry(i) - if entry: - assert entry.name != ".." + # ".." entry should be present unless we're at filesystem root + # Since temp_folder is not a filesystem root, ".." should appear + has_parent_entry = any( + model.get_entry(i) and model.get_entry(i).name == ".." + for i in range(model.rowCount()) + ) + # temp_folder is not a filesystem root, so ".." should be present + assert has_parent_entry, "Expected '..' entry for non-root directory" class TestThumbnailModelSelection: """Tests for selection functionality.""" - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_select_single(self, mock_find_images, model, temp_folder): """Test selecting a single image.""" from faststack.models import ImageFile @@ -202,7 +213,7 @@ def test_select_single(self, mock_find_images, model, temp_folder): selected = model.get_selected_paths() assert len(selected) == 1 - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_ctrl_click_toggle(self, mock_find_images, model, temp_folder): """Test Ctrl+click toggles selection.""" from faststack.models import ImageFile @@ -232,7 +243,7 @@ def test_ctrl_click_toggle(self, mock_find_images, model, temp_folder): model.select_index(img_indices[0], shift=False, ctrl=True) assert len(model.get_selected_paths()) == 1 - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_clear_selection(self, mock_find_images, model, temp_folder): """Test clearing selection.""" from faststack.models import ImageFile @@ -255,10 +266,9 @@ def test_clear_selection(self, mock_find_images, model, temp_folder): model.clear_selection() assert len(model.get_selected_paths()) == 0 - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_cannot_select_folders(self, mock_find_images, model): """Test that folders cannot be selected.""" - from faststack.models import ImageFile mock_find_images.return_value = [] @@ -278,10 +288,9 @@ def test_cannot_select_folders(self, mock_find_images, model): class TestThumbnailModelNavigation: """Tests for navigation functionality.""" - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_navigate_to_subfolder(self, mock_find_images, model, temp_folder): """Test navigating to a subfolder.""" - from faststack.models import ImageFile subfolder = temp_folder / "subfolder" mock_find_images.return_value = [] @@ -290,10 +299,9 @@ def test_navigate_to_subfolder(self, mock_find_images, model, temp_folder): assert model.current_directory == subfolder.resolve() - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_cannot_navigate_outside_base(self, mock_find_images, model, temp_folder): """Test that navigation outside base directory is blocked.""" - from faststack.models import ImageFile mock_find_images.return_value = [] @@ -303,7 +311,7 @@ def test_cannot_navigate_outside_base(self, mock_find_images, model, temp_folder # Should still be at base assert model.current_directory == temp_folder.resolve() - @patch('faststack.thumbnail_view.model.find_images') + @patch("faststack.thumbnail_view.model.find_images") def test_navigation_clears_selection(self, mock_find_images, model, temp_folder): """Test that navigation clears selection.""" from faststack.models import ImageFile @@ -329,3 +337,60 @@ def test_navigation_clears_selection(self, mock_find_images, model, temp_folder) # Selection should be cleared assert len(model.get_selected_paths()) == 0 + + +class TestIsFilesystemRoot: + """Tests for _is_filesystem_root function.""" + + def test_unix_root(self): + """Test that / is detected as root on Unix.""" + assert _is_filesystem_root(Path("/")) is True + + def test_non_root_unix_path(self, temp_folder): + """Test that a non-root path is not detected as root.""" + assert _is_filesystem_root(temp_folder) is False + + def test_deep_path_not_root(self): + """Test that a deep path is not detected as root.""" + assert _is_filesystem_root(Path("/home/user/documents")) is False + + def test_path_with_resolve(self, temp_folder): + """Test that path is resolved before checking.""" + # Create a relative path that resolves to temp_folder + resolved = temp_folder.resolve() + assert _is_filesystem_root(resolved) is False + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") + def test_windows_drive_root(self): + """Test Windows drive root detection (e.g., C:\\).""" + # Test C:\ format (only meaningful on Windows) + assert _is_filesystem_root(Path("C:\\")) is True + assert _is_filesystem_root(Path("D:\\")) is True + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") + def test_windows_non_root_path(self): + """Test that a Windows non-root path is not detected as root.""" + assert _is_filesystem_root(Path("C:\\Users\\test")) is False + assert _is_filesystem_root(Path("D:\\data\\folder")) is False + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") + def test_unc_path_root(self): + """Test UNC root detection (\\server\\share format).""" + # \\server\share is the share root level (only on Windows) + assert _is_filesystem_root(Path("\\\\server\\share")) is True + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") + def test_unc_path_non_root(self): + """Test that UNC subpaths are not detected as root.""" + # \\server\share\folder is NOT a root + assert _is_filesystem_root(Path("\\\\server\\share\\folder")) is False + # \\server\share\folder\subfolder is NOT a root + assert ( + _is_filesystem_root(Path("\\\\server\\share\\folder\\subfolder")) is False + ) + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") + def test_unc_server_only_not_root(self): + """Test that \\server alone is not considered a root (requires share).""" + # Just \\server (no share) shouldn't be a root according to implementation + assert _is_filesystem_root(Path("\\\\server")) is False diff --git a/faststack/tests/thumbnail_view/test_prefetcher.py b/faststack/tests/thumbnail_view/test_prefetcher.py index 0f01fe5..6cd4b6f 100644 --- a/faststack/tests/thumbnail_view/test_prefetcher.py +++ b/faststack/tests/thumbnail_view/test_prefetcher.py @@ -2,10 +2,8 @@ import pytest import time -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from PIL import Image -import numpy as np from faststack.thumbnail_view.prefetcher import ( ThumbnailPrefetcher, @@ -221,7 +219,6 @@ def test_decode_applies_exif_orientation(self, cache, temp_folder): img = Image.new("RGB", (400, 200), color="blue") # Save with EXIF orientation (rotated 90 CW) - from PIL.ExifTags import TAGS exif_dict = img.getexif() exif_dict[274] = 6 # Orientation tag = 6 (90 CW) diff --git a/faststack/tests/thumbnail_view/test_provider.py b/faststack/tests/thumbnail_view/test_provider.py index e56f500..d91978c 100644 --- a/faststack/tests/thumbnail_view/test_provider.py +++ b/faststack/tests/thumbnail_view/test_provider.py @@ -1,8 +1,7 @@ """Tests for PathResolver (ThumbnailProvider requires Qt GUI).""" -import pytest from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from faststack.thumbnail_view.provider import PathResolver diff --git a/faststack/tests/thumbnail_view/test_selection.py b/faststack/tests/thumbnail_view/test_selection.py index b7eea64..6406721 100644 --- a/faststack/tests/thumbnail_view/test_selection.py +++ b/faststack/tests/thumbnail_view/test_selection.py @@ -1,8 +1,6 @@ """Tests for selection functionality in ThumbnailModel.""" import pytest -from pathlib import Path -from unittest.mock import MagicMock, patch from faststack.thumbnail_view.model import ThumbnailModel, ThumbnailEntry @@ -28,11 +26,36 @@ def model_with_images(temp_folder): # Manually populate entries for testing model._entries = [ - ThumbnailEntry(path=temp_folder / "image0.jpg", name="image0.jpg", is_folder=False, mtime_ns=1000), - ThumbnailEntry(path=temp_folder / "image1.jpg", name="image1.jpg", is_folder=False, mtime_ns=1001), - ThumbnailEntry(path=temp_folder / "image2.jpg", name="image2.jpg", is_folder=False, mtime_ns=1002), - ThumbnailEntry(path=temp_folder / "image3.jpg", name="image3.jpg", is_folder=False, mtime_ns=1003), - ThumbnailEntry(path=temp_folder / "image4.jpg", name="image4.jpg", is_folder=False, mtime_ns=1004), + ThumbnailEntry( + path=temp_folder / "image0.jpg", + name="image0.jpg", + is_folder=False, + mtime_ns=1000, + ), + ThumbnailEntry( + path=temp_folder / "image1.jpg", + name="image1.jpg", + is_folder=False, + mtime_ns=1001, + ), + ThumbnailEntry( + path=temp_folder / "image2.jpg", + name="image2.jpg", + is_folder=False, + mtime_ns=1002, + ), + ThumbnailEntry( + path=temp_folder / "image3.jpg", + name="image3.jpg", + is_folder=False, + mtime_ns=1003, + ), + ThumbnailEntry( + path=temp_folder / "image4.jpg", + name="image4.jpg", + is_folder=False, + mtime_ns=1004, + ), ] return model @@ -175,8 +198,12 @@ def test_cannot_select_folder(self, temp_folder): # Add a folder entry model._entries = [ - ThumbnailEntry(path=temp_folder / "subfolder", name="subfolder", is_folder=True), - ThumbnailEntry(path=temp_folder / "image.jpg", name="image.jpg", is_folder=False), + ThumbnailEntry( + path=temp_folder / "subfolder", name="subfolder", is_folder=True + ), + ThumbnailEntry( + path=temp_folder / "image.jpg", name="image.jpg", is_folder=False + ), ] # Try to select folder @@ -195,9 +222,15 @@ def test_shift_click_skips_folders(self, temp_folder): # Add mixed entries model._entries = [ - ThumbnailEntry(path=temp_folder / "image0.jpg", name="image0.jpg", is_folder=False), - ThumbnailEntry(path=temp_folder / "subfolder", name="subfolder", is_folder=True), - ThumbnailEntry(path=temp_folder / "image1.jpg", name="image1.jpg", is_folder=False), + ThumbnailEntry( + path=temp_folder / "image0.jpg", name="image0.jpg", is_folder=False + ), + ThumbnailEntry( + path=temp_folder / "subfolder", name="subfolder", is_folder=True + ), + ThumbnailEntry( + path=temp_folder / "image1.jpg", name="image1.jpg", is_folder=False + ), ] # Select first image, then shift-click third @@ -292,7 +325,9 @@ def test_returns_only_files(self, temp_folder): ) model._entries = [ - ThumbnailEntry(path=temp_folder / "image.jpg", name="image.jpg", is_folder=False), + ThumbnailEntry( + path=temp_folder / "image.jpg", name="image.jpg", is_folder=False + ), ] model.select_index(0, shift=False, ctrl=False) diff --git a/faststack/thumbnail_view/folder_stats.py b/faststack/thumbnail_view/folder_stats.py index 847e8fd..33f1c99 100644 --- a/faststack/thumbnail_view/folder_stats.py +++ b/faststack/thumbnail_view/folder_stats.py @@ -2,7 +2,9 @@ import json import logging -from dataclasses import dataclass +import os +from collections import Counter +from dataclasses import dataclass, field from pathlib import Path from typing import Dict, Optional, Tuple @@ -14,16 +16,40 @@ @dataclass class FolderStats: """Statistics parsed from a folder's faststack.json file.""" + total_images: int stacked_count: int uploaded_count: int edited_count: int + # Count of image-like files (JPG, JPEG, PNG, GIF, BMP, TIFF, TIF, WEBP) + # Named 'jpg_count' for historical reasons; displayed as "IMG" in UI + jpg_count: int = 0 + raw_count: int = 0 + # Coverage sparkline data: list of (upload_ratio, stack_ratio) tuples per bucket + # Each ratio is 0.0-1.0, representing the fraction of JPGs in that bucket + # that have the flag set. Empty list if no faststack.json or no JPGs. + coverage_buckets: list[tuple[float, float]] = field(default_factory=list) + + +# Cache by (folder_path, json_mtime_ns, folder_mtime_ns) to avoid re-parsing during scroll +# IMPORTANT: Both json_mtime_ns and folder_mtime_ns are needed: +# - json_mtime_ns: changes when faststack.json is modified (flags, metadata) +# - folder_mtime_ns: changes when files are added/removed/renamed in folder +# - folder_mtime_ns: changes when files are added/removed/renamed in folder +_stats_cache: Dict[Tuple[Path, int, int], Optional[FolderStats]] = {} +MAX_CACHE_SIZE = 1000 -# Cache by (folder_path, json_mtime_ns) to avoid re-parsing during scroll -# IMPORTANT: json_mtime_ns = stat(folder_path / "faststack.json").st_mtime_ns -# NOT the folder's mtime (folder mtime changes when any file is added/removed) -_stats_cache: Dict[Tuple[Path, int], Optional[FolderStats]] = {} +def _check_cache_size(cache_dict): + """Enforce maximum cache size by removing oldest entries (FIFO).""" + if len(cache_dict) > MAX_CACHE_SIZE: + # Remove a chunk to amortize cost + excess = len(cache_dict) - MAX_CACHE_SIZE + 10 + for _ in range(excess): + if not cache_dict: + break + # dict order is insertion order in modern Python, so this is FIFO + cache_dict.pop(next(iter(cache_dict))) def read_folder_stats(folder_path: Path) -> Optional[FolderStats]: @@ -34,20 +60,35 @@ def read_folder_stats(folder_path: Path) -> Optional[FolderStats]: Returns: FolderStats if valid faststack.json exists, None otherwise. - Caches results by (folder_path, json_mtime_ns) to avoid re-parsing. + Caches results by (folder_path, json_mtime_ns, folder_mtime_ns) to avoid + re-parsing during scrolling. Cache invalidates when either faststack.json + or the folder contents change. + + Note: + Cache key uses both json file's mtime_ns and folder mtime_ns for invalidation. + On some filesystems with coarse time granularity (e.g., FAT32, some network + mounts), rapid edits within the same second may not trigger cache invalidation. + This is rare and acceptable for UI display purposes. Call clear_stats_cache() + explicitly if stale data is suspected. """ json_path = folder_path / SIDECAR_FILENAME # Check if file exists try: stat_info = json_path.stat() - mtime_ns = stat_info.st_mtime_ns + json_mtime_ns = stat_info.st_mtime_ns except (OSError, FileNotFoundError): # No faststack.json in this folder return None - # Check cache - cache_key = (folder_path.resolve(), mtime_ns) + # Get folder mtime for cache invalidation when files are added/removed + try: + folder_mtime_ns = folder_path.stat().st_mtime_ns + except OSError: + folder_mtime_ns = 0 # Fallback if stat fails + + # Check cache using both mtime values for invalidation + cache_key = (folder_path.resolve(), json_mtime_ns, folder_mtime_ns) if cache_key in _stats_cache: return _stats_cache[cache_key] @@ -55,11 +96,62 @@ def read_folder_stats(folder_path: Path) -> Optional[FolderStats]: stats = _parse_faststack_json(json_path) # Cache the result (even if None) + # Cache the result (even if None) + _check_cache_size(_stats_cache) _stats_cache[cache_key] = stats return stats +# Extensions considered as JPG (processed images) +JPG_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp"} + +# Extensions considered as RAW (camera raw files) +RAW_EXTENSIONS = { + ".orf", + ".cr2", + ".cr3", + ".nef", + ".arw", + ".dng", + ".rw2", + ".raf", + ".pef", +} + + +def _scan_folder_files(folder_path: Path) -> Tuple[int, int, list]: + """Single-pass scan to count files and collect JPG names. + + Performs one os.scandir pass to gather all file statistics needed + for folder stats and coverage sparkline computation. + + Returns: + Tuple of (jpg_count, raw_count, jpg_filenames_sorted) + jpg_filenames_sorted is a list of JPG filenames sorted alphabetically. + """ + jpg_count = 0 + raw_count = 0 + jpg_files = [] + try: + for entry in os.scandir(folder_path): + if entry.is_file(): + # FASTER: os.path.splitext is string-based, avoids Path object creation + _, suffix = os.path.splitext(entry.name) + suffix = suffix.lower() + if suffix in JPG_EXTENSIONS: + jpg_count += 1 + jpg_files.append(entry.name) + elif suffix in RAW_EXTENSIONS: + raw_count += 1 + except OSError: + pass + + # Sort JPG files alphabetically (matches find_images default sort) + jpg_files.sort(key=str.lower) + return jpg_count, raw_count, jpg_files + + def _parse_faststack_json(json_path: Path) -> Optional[FolderStats]: """Parse a faststack.json file and extract statistics. @@ -103,16 +195,176 @@ def _parse_faststack_json(json_path: Path) -> Optional[FolderStats]: if meta.get("edited", False): edited_count += 1 + # Single-pass scan: count file types AND collect JPG filenames for sparkline + folder_path = json_path.parent + jpg_count, raw_count, jpg_files = _scan_folder_files(folder_path) + + # Compute coverage buckets for sparkline (using pre-collected JPG list) + coverage_buckets = _compute_coverage_buckets(jpg_files, entries) + return FolderStats( total_images=total_images, stacked_count=stacked_count, uploaded_count=uploaded_count, edited_count=edited_count, + jpg_count=jpg_count, + raw_count=raw_count, + coverage_buckets=coverage_buckets, ) +def _compute_coverage_buckets( + jpg_files: list, entries: Dict[str, dict], num_buckets: int = 40 +) -> list: + """Compute coverage sparkline buckets for uploads and stacks. + + Returns a list of (upload_ratio, stack_ratio) tuples, one per bucket. + Each ratio is 0.0-1.0, representing the fraction of JPGs in that bucket + with the respective flag set. + + Args: + jpg_files: Pre-sorted list of JPG filenames (from _scan_folder_files) + entries: Dict of {stem: metadata} from faststack.json + num_buckets: Number of buckets to divide files into (default 40) + + Returns: + List of (upload_ratio, stack_ratio) tuples, or empty list if no JPGs. + """ + if not jpg_files: + return [] + + total_files = len(jpg_files) + if total_files < num_buckets: + num_buckets = total_files + + # Single-pass accumulation into buckets to avoid redundant list processing + # Each entry is [uploaded_count, stacked_count, total_in_bucket] + accumulators = [[0, 0, 0] for _ in range(num_buckets)] + + for i, filename in enumerate(jpg_files): + # Map file index to bucket index using floor division + bucket_idx = (i * num_buckets) // total_files + + # Efficient stem extraction and metadata lookup + stem, _ = os.path.splitext(filename) + meta = entries.get(stem) + + if isinstance(meta, dict): + if meta.get("uploaded", False): + accumulators[bucket_idx][0] += 1 + if meta.get("stacked", False): + accumulators[bucket_idx][1] += 1 + + accumulators[bucket_idx][2] += 1 + + # Convert counts to ratios + buckets = [] + for uploaded, stacked, count in accumulators: + if count == 0: + buckets.append((0.0, 0.0)) + else: + buckets.append((uploaded / count, stacked / count)) + + return buckets + + def clear_stats_cache(): """Clear the folder stats cache.""" global _stats_cache _stats_cache.clear() log.debug("Cleared folder stats cache") + + +def count_images_in_folder(folder_path: Path) -> Optional[FolderStats]: + """Count actual image files in a folder (for folders without faststack.json). + + Uses folder mtime for cache key since there's no faststack.json to track. + This is less efficient than faststack.json but works for special folders + like the recycle bin. + + Args: + folder_path: Path to the folder to count images in + + Returns: + FolderStats with total_images count and jpg/raw breakdown (other counts will be 0) + """ + try: + stat_info = folder_path.stat() + mtime_ns = stat_info.st_mtime_ns + except (OSError, FileNotFoundError): + return None + + # Use a different cache key prefix to avoid collision with faststack.json cache + cache_key = (folder_path.resolve(), mtime_ns) + # Check if we have this in a separate "raw count" cache + if cache_key in _raw_count_cache: + return _raw_count_cache[cache_key] + + # Count image files using shared scan function + jpg_count, raw_count, _ = _scan_folder_files(folder_path) + total_count = jpg_count + raw_count + if total_count == 0: + _raw_count_cache[cache_key] = None + return None + + stats = FolderStats( + total_images=total_count, + stacked_count=0, + uploaded_count=0, + edited_count=0, + jpg_count=jpg_count, + raw_count=raw_count, + ) + _check_cache_size(_raw_count_cache) + _raw_count_cache[cache_key] = stats + return stats + + +# Separate cache for raw file counts (folders without faststack.json) +_raw_count_cache: Dict[Tuple[Path, int], Optional[FolderStats]] = {} + + +def get_file_counts_by_extension(folder_path: Path) -> Dict[str, int]: + """Count files by extension in a folder, excluding faststack.json. + + Image-like extensions (.jpg, .jpeg, .png, etc. from JPG_EXTENSIONS) are + grouped under "IMG" for cleaner display. RAW extensions keep their real + labels. Other extensions are shown as-is. + + Args: + folder_path: Path to the folder to count files in + + Returns: + Dictionary mapping uppercase extension (without dot) to count. + Example: {"IMG": 10, "ORF": 10, "TXT": 1} + """ + counts: Counter[str] = Counter() + try: + for entry in os.scandir(folder_path): + if entry.is_file(): + name = entry.name + # Skip faststack.json + if name == SIDECAR_FILENAME: + continue + # FASTER: os.path.splitext is string-based, avoids Path object creation + _, suffix = os.path.splitext(name) + suffix_lower = suffix.lower() + if suffix_lower: + # Group image-like extensions under "IMG" + if suffix_lower in JPG_EXTENSIONS: + counts["IMG"] += 1 + else: + # Convert to uppercase without dot for display + ext = suffix_lower[1:].upper() + counts[ext] += 1 + except OSError as e: + log.debug("Failed to scan %s: %s", folder_path, e) + + return dict(counts) + + +def clear_raw_count_cache(): + """Clear the raw file count cache.""" + global _raw_count_cache + _raw_count_cache.clear() + log.debug("Cleared raw count cache") diff --git a/faststack/thumbnail_view/model.py b/faststack/thumbnail_view/model.py index a77448e..23e282f 100644 --- a/faststack/thumbnail_view/model.py +++ b/faststack/thumbnail_view/model.py @@ -3,7 +3,7 @@ import hashlib import logging import os -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Set, Callable @@ -16,14 +16,56 @@ ) from faststack.io.indexer import find_images -from faststack.thumbnail_view.folder_stats import FolderStats, read_folder_stats +from faststack.thumbnail_view.folder_stats import ( + FolderStats, + count_images_in_folder, + read_folder_stats, +) log = logging.getLogger(__name__) +def _is_filesystem_root(path: Path) -> bool: + r"""Check if a path is a filesystem root. + + Handles: + - Unix roots: / + - Windows drive roots: C:\, D:\, etc. + - UNC roots: \\server\share (the share level is treated as root) + + Args: + path: Path to check + + Returns: + True if the path is a filesystem root. + """ + resolved = path.resolve() + + # Check if path equals its own parent (root condition) + if resolved == resolved.parent: + return True + + # On Windows, check for drive root (e.g., C:\) + path_str = str(resolved) + if len(path_str) <= 3 and path_str[1:3] == ":\\": + return True + + # Check for UNC root (\\server\share) + if path_str.startswith("\\\\"): + # UNC paths: \\server\share is the root level + # Count backslashes after the initial \\ + parts = path_str[2:].split("\\") + # \\server\share has 2 parts (server, share) + if len(parts) <= 2: + return True + + return False + + @dataclass class ThumbnailEntry: """A single entry in the thumbnail grid (file or folder).""" + path: Path name: str is_folder: bool @@ -144,6 +186,12 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): "stacked_count": entry.folder_stats.stacked_count, "uploaded_count": entry.folder_stats.uploaded_count, "edited_count": entry.folder_stats.edited_count, + "jpg_count": entry.folder_stats.jpg_count, # Actually image-like files: JPG, PNG, etc. + "raw_count": entry.folder_stats.raw_count, + # Convert tuples to lists for safer QML type conversion + "coverage_buckets": [ + list(t) for t in entry.folder_stats.coverage_buckets + ], } return None elif role == self.IsSelectedRole: @@ -181,7 +229,7 @@ def _get_loupe_index_for_entry(self, entry: ThumbnailEntry) -> Optional[int]: # This requires access to the app controller's _path_to_index # We'll use the parent (AppController) to look this up parent = self.parent() - if parent and hasattr(parent, '_path_to_index'): + if parent and hasattr(parent, "_path_to_index"): return parent._path_to_index.get(entry.path.resolve()) return None @@ -228,103 +276,120 @@ def refresh(self, filter_string: str = ""): filter_string: Optional filter to apply to filenames (case-insensitive) """ self.beginResetModel() - - self._entries.clear() - self._id_to_row.clear() - self._selected_indices.clear() - self._last_selected_index = None - - # Add parent folder entry if not at base - if self._current_directory != self._base_directory: - parent_path = self._current_directory.parent - self._entries.append(ThumbnailEntry( - path=parent_path, - name="..", - is_folder=True, - mtime_ns=0, - )) - - # Scan for folders - folders: List[ThumbnailEntry] = [] try: - for entry in os.scandir(self._current_directory): - if entry.is_dir() and not entry.name.startswith("."): - folder_path = Path(entry.path) - try: - stat_info = entry.stat() - mtime_ns = stat_info.st_mtime_ns - except OSError: - mtime_ns = 0 - - folder_stats = read_folder_stats(folder_path) - - folders.append(ThumbnailEntry( - path=folder_path, - name=entry.name, + self._entries.clear() + self._id_to_row.clear() + self._selected_indices.clear() + self._last_selected_index = None + + # Add parent folder entry if not at filesystem root + # (allows navigating up even above base_directory) + if not _is_filesystem_root(self._current_directory): + parent_path = self._current_directory.parent + self._entries.append( + ThumbnailEntry( + path=parent_path, + name="..", is_folder=True, - folder_stats=folder_stats, - mtime_ns=mtime_ns, - )) - except OSError as e: - log.warning("Error scanning directory %s: %s", self._current_directory, e) + mtime_ns=0, + ) + ) - # Sort folders alphabetically - folders.sort(key=lambda e: e.name.lower()) - self._entries.extend(folders) + # Scan for folders + folders: List[ThumbnailEntry] = [] + try: + for entry in os.scandir(self._current_directory): + if entry.is_dir() and not entry.name.startswith("."): + folder_path = Path(entry.path) + try: + stat_info = entry.stat() + mtime_ns = stat_info.st_mtime_ns + except OSError: + mtime_ns = 0 + + folder_stats = read_folder_stats(folder_path) + # Fall back to counting actual files for folders without faststack.json + # (e.g., recycle bin) + if folder_stats is None: + folder_stats = count_images_in_folder(folder_path) + + folders.append( + ThumbnailEntry( + path=folder_path, + name=entry.name, + is_folder=True, + folder_stats=folder_stats, + mtime_ns=mtime_ns, + ) + ) + except OSError as e: + log.warning( + "Error scanning directory %s: %s", self._current_directory, e + ) + + # Sort folders alphabetically + folders.sort(key=lambda e: e.name.lower()) + self._entries.extend(folders) + + # Get images using existing indexer (respects filter rules) + images = find_images(self._current_directory) + + # Apply filter if specified + if filter_string: + needle = filter_string.lower() + images = [img for img in images if needle in img.path.stem.lower()] + + # Convert ImageFile to ThumbnailEntry + for img in images: + try: + stat_info = img.path.stat() + mtime_ns = stat_info.st_mtime_ns + except OSError: + mtime_ns = int(img.timestamp * 1e9) if img.timestamp else 0 + + # Get metadata if callback provided + is_stacked = False + is_uploaded = False + is_edited = False + is_restacked = False + + if self._get_metadata: + try: + meta = self._get_metadata(img.path.stem) + is_stacked = meta.get("stacked", False) + is_uploaded = meta.get("uploaded", False) + is_edited = meta.get("edited", False) + is_restacked = meta.get("restacked", False) + except Exception: + pass + + self._entries.append( + ThumbnailEntry( + path=img.path, + name=img.path.name, + is_folder=False, + is_stacked=is_stacked, + is_uploaded=is_uploaded, + is_edited=is_edited, + is_restacked=is_restacked, + mtime_ns=mtime_ns, + ) + ) - # Get images using existing indexer (respects filter rules) - images = find_images(self._current_directory) + # Build id_to_row mapping + self._rebuild_id_mapping() - # Apply filter if specified - if filter_string: - needle = filter_string.lower() - images = [img for img in images if needle in img.path.stem.lower()] + finally: + self.endResetModel() - # Convert ImageFile to ThumbnailEntry - for img in images: - try: - stat_info = img.path.stat() - mtime_ns = stat_info.st_mtime_ns - except OSError: - mtime_ns = int(img.timestamp * 1e9) if img.timestamp else 0 - - # Get metadata if callback provided - is_stacked = False - is_uploaded = False - is_edited = False - is_restacked = False - - if self._get_metadata: - try: - meta = self._get_metadata(img.path.stem) - is_stacked = meta.get("stacked", False) - is_uploaded = meta.get("uploaded", False) - is_edited = meta.get("edited", False) - is_restacked = meta.get("restacked", False) - except Exception: - pass - - self._entries.append(ThumbnailEntry( - path=img.path, - name=img.path.name, - is_folder=False, - is_stacked=is_stacked, - is_uploaded=is_uploaded, - is_edited=is_edited, - is_restacked=is_restacked, - mtime_ns=mtime_ns, - )) - - # Build id_to_row mapping - self._rebuild_id_mapping() - - self.endResetModel() # Selection was cleared during refresh self.selectionChanged.emit() - log.info("ThumbnailModel refreshed: %d entries (%d folders, %d images)", - len(self._entries), - sum(1 for e in self._entries if e.is_folder), - sum(1 for e in self._entries if not e.is_folder)) + log.info( + "ThumbnailModel refreshed: %d entries (%d folders, %d images)", + len(self._entries), + sum(1 for e in self._entries if e.is_folder), + sum(1 for e in self._entries if not e.is_folder), + ) def _rebuild_id_mapping(self): """Rebuild the id_to_row mapping for all entries.""" @@ -375,25 +440,38 @@ def set_directories(self, base_directory: Path, current_directory: Path): self._last_selected_index = None # Don't call refresh() here - caller should do it after updating other state - def navigate_to(self, path: Path): + def navigate_to(self, path: Path, update_base_if_above: bool = False): """Navigate to a different directory. Args: - path: Directory to navigate to. Must be within base_directory. + path: Directory to navigate to. + update_base_if_above: If True and path is above base_directory, + update base_directory to path. Used for + "go up" navigation above initial directory. """ resolved = path.resolve() - # Security check: don't navigate outside base directory - try: - resolved.relative_to(self._base_directory) - except ValueError: - log.warning("Attempted to navigate outside base directory: %s", resolved) - return - if not resolved.is_dir(): log.warning("Cannot navigate to non-directory: %s", resolved) return + # Check if navigating above base directory + try: + resolved.relative_to(self._base_directory) + except ValueError: + # path is outside base_directory + if update_base_if_above: + # Allow navigation up - update base_directory + log.info( + "Navigating above base directory, updating base to: %s", resolved + ) + self._base_directory = resolved + else: + log.warning( + "Attempted to navigate outside base directory: %s", resolved + ) + return + self._current_directory = resolved self._selected_indices.clear() self._last_selected_index = None diff --git a/faststack/thumbnail_view/prefetcher.py b/faststack/thumbnail_view/prefetcher.py index 882b6df..fcdca7e 100644 --- a/faststack/thumbnail_view/prefetcher.py +++ b/faststack/thumbnail_view/prefetcher.py @@ -18,6 +18,7 @@ # Try to import turbojpeg for faster JPEG decoding try: from turbojpeg import TurboJPEG, TJPF_RGB, TJSAMP_444 + _tj = TurboJPEG() HAS_TURBOJPEG = True except ImportError: @@ -62,7 +63,9 @@ def __init__( self._cache = cache self._on_ready = on_ready_callback self._target_size = target_size - self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="thumb") + self._executor = ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix="thumb" + ) # Track in-flight jobs to avoid duplicates # Key: (size, path_hash, mtime_ns) @@ -72,8 +75,11 @@ def __init__( # Track futures for potential cancellation self._futures: Dict[Tuple[int, str, int], Future] = {} - log.info("ThumbnailPrefetcher initialized with %d workers, target size %dpx", - max_workers, target_size) + log.info( + "ThumbnailPrefetcher initialized with %d workers, target size %dpx", + max_workers, + target_size, + ) def submit(self, path: Path, mtime_ns: int, size: int = None) -> bool: """Submit a thumbnail decode job. @@ -112,7 +118,9 @@ def submit(self, path: Path, mtime_ns: int, size: int = None) -> bool: mtime_ns, size, ) - future.add_done_callback(lambda f: self._on_decode_done(f, job_key, cache_key)) + future.add_done_callback( + lambda f: self._on_decode_done(f, job_key, cache_key) + ) with self._inflight_lock: self._futures[job_key] = future @@ -161,6 +169,7 @@ def _decode_worker( # Use BytesIO to encode to JPEG import io + buf = io.BytesIO() pil_image.save(buf, format="JPEG", quality=85) return buf.getvalue() @@ -188,26 +197,34 @@ def _decode_image(self, path: Path, target_size: int) -> Optional[np.ndarray]: # Calculate scale factor for turbojpeg (powers of 2: 1, 2, 4, 8) scale_factor = 1 - while (width // (scale_factor * 2) >= target_size and - height // (scale_factor * 2) >= target_size and - scale_factor < 8): + while ( + width // (scale_factor * 2) >= target_size + and height // (scale_factor * 2) >= target_size + and scale_factor < 8 + ): scale_factor *= 2 # Decode with scaling scaling_factor = (1, scale_factor) - rgb = _tj.decode(jpeg_data, pixel_format=TJPF_RGB, scaling_factor=scaling_factor) + rgb = _tj.decode( + jpeg_data, pixel_format=TJPF_RGB, scaling_factor=scaling_factor + ) # Further resize with PIL if needed h, w = rgb.shape[:2] if w > target_size or h > target_size: pil_img = Image.fromarray(rgb) - pil_img.thumbnail((target_size, target_size), Image.Resampling.LANCZOS) + pil_img.thumbnail( + (target_size, target_size), Image.Resampling.LANCZOS + ) rgb = np.array(pil_img) return rgb except Exception as e: - log.debug("TurboJPEG decode failed for %s, falling back to PIL: %s", path, e) + log.debug( + "TurboJPEG decode failed for %s, falling back to PIL: %s", path, e + ) # Fallback to PIL try: @@ -224,7 +241,9 @@ def _decode_image(self, path: Path, target_size: int) -> Optional[np.ndarray]: log.debug("PIL decode failed for %s: %s", path, e) return None - def _on_decode_done(self, future: Future, job_key: Tuple[int, str, int], cache_key: str): + def _on_decode_done( + self, future: Future, job_key: Tuple[int, str, int], cache_key: str + ): """Callback when decode completes.""" # Remove from inflight with self._inflight_lock: @@ -301,8 +320,10 @@ def put(self, key: str, value: bytes): self._current_bytes += len(value) # Evict if over limits - while (self._current_bytes > self._max_bytes or - len(self._cache) > self._max_items) and self._order: + while ( + self._current_bytes > self._max_bytes + or len(self._cache) > self._max_items + ) and self._order: oldest = self._order.pop(0) if oldest in self._cache: self._current_bytes -= len(self._cache[oldest]) diff --git a/faststack/thumbnail_view/provider.py b/faststack/thumbnail_view/provider.py index 9693c1e..41e9cc5 100644 --- a/faststack/thumbnail_view/provider.py +++ b/faststack/thumbnail_view/provider.py @@ -1,6 +1,5 @@ """QML Image Provider for thumbnail grid view.""" -import io import logging from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -94,16 +93,12 @@ def _create_folder_placeholder(self, size: int) -> QPixmap: margin, margin + tab_height, size - 2 * margin, - size - 2 * margin - tab_height + size - 2 * margin - tab_height, ) # Tab extension painter.fillRect( - margin, - margin, - tab_width, - tab_height + 2, - QColor(100, 100, 100) + margin, margin, tab_width, tab_height + 2, QColor(100, 100, 100) ) painter.end() @@ -210,6 +205,7 @@ def clear(self): def update_from_model(self, model: "ThumbnailModel"): """Update registrations from a ThumbnailModel.""" import hashlib + self.clear() for i in range(model.rowCount()): entry = model.get_entry(i) diff --git a/faststack/ui/keystrokes.py b/faststack/ui/keystrokes.py index c870a66..d3d493e 100644 --- a/faststack/ui/keystrokes.py +++ b/faststack/ui/keystrokes.py @@ -4,6 +4,7 @@ log = logging.getLogger(__name__) + class Keybinder: def __init__(self, controller): """ @@ -16,31 +17,28 @@ def __init__(self, controller): # map keys → method names (not callables) self.key_map = { + # View switching + Qt.Key_Escape: "switch_to_grid_view", # Navigation Qt.Key_J: "next_image", Qt.Key_Right: "next_image", Qt.Key_K: "prev_image", Qt.Key_Left: "prev_image", Qt.Key_G: "show_jump_to_image_dialog", - # Stacking Qt.Key_BracketLeft: "begin_new_stack", Qt.Key_BracketRight: "end_current_stack", Qt.Key_S: "toggle_stack_membership", - # Batching Qt.Key_BraceLeft: "begin_new_batch", Qt.Key_BraceRight: "end_current_batch", Qt.Key_Backslash: "clear_all_batches", Qt.Key_B: "toggle_batch_membership", - # Remove from batch/stack Qt.Key_X: "remove_from_batch_or_stack", - # Toggle flags Qt.Key_U: "toggle_uploaded", Qt.Key_I: "show_exif_dialog", - # Actions Qt.Key_Enter: "launch_helicon", Qt.Key_Return: "launch_helicon", @@ -60,8 +58,10 @@ def __init__(self, controller): (Qt.Key_Z, Qt.ControlModifier): "undo_delete", (Qt.Key_E, Qt.ControlModifier): "toggle_edited", (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", - - (Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier): "quick_auto_white_balance", + ( + Qt.Key_B, + Qt.ControlModifier | Qt.ShiftModifier, + ): "quick_auto_white_balance", (Qt.Key_1, Qt.ControlModifier): "zoom_100", (Qt.Key_2, Qt.ControlModifier): "zoom_200", (Qt.Key_3, Qt.ControlModifier): "zoom_300", @@ -81,7 +81,9 @@ def _call(self, method_name: str): getattr(self.controller, method_name)() return - log.warning(f"Keybinder: neither main_window nor controller has '{method_name}'") + log.warning( + f"Keybinder: neither main_window nor controller has '{method_name}'" + ) def handle_key_press(self, event): key = event.key() @@ -93,7 +95,9 @@ def handle_key_press(self, event): for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): # Check if required modifier is present in event modifiers if key == mapped_key and (modifiers & mapped_modifier): - log.debug(f"Matched modifier key: {key} + {mapped_modifier} -> {method_name}") + log.debug( + f"Matched modifier key: {key} + {mapped_modifier} -> {method_name}" + ) self._call(method_name) return True diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 8af029e..0781e53 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -2,11 +2,11 @@ import logging import collections +import threading from PySide6.QtCore import QObject, Signal, Property, Slot, Qt from PySide6.QtGui import QImage from PySide6.QtQuick import QQuickImageProvider -from faststack.models import DecodedImage from faststack.config import config from faststack.imaging.optional_deps import HAS_OPENCV from pathlib import Path @@ -14,6 +14,7 @@ # Try to import QColorSpace if available (Qt 6+) try: from PySide6.QtGui import QColorSpace + HAS_COLOR_SPACE = True except ImportError: HAS_COLOR_SPACE = False @@ -31,6 +32,8 @@ def __init__(self, app_controller): # Increased to 128 to prevent crashes during rapid scrolling/thrashing where # QML might hold onto textures slightly longer than the Python GC expects. self._keepalive = collections.deque(maxlen=128) + # Lock to protect keepalive deque from concurrent access by QML rendering threads + self._keepalive_lock = threading.Lock() def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: """Handles image requests from QML.""" @@ -39,33 +42,38 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: try: # Parse index and optional generation - parts = id.split('/') + parts = id.split("/") index = int(parts[0]) gen = int(parts[1]) if len(parts) > 1 else None - + # If editor is open, use the background-rendered preview buffer # BUT only if the requested index matches the currently edited index! # AND the generation matches (to avoid stale frames during rotation/param changes) # FIX: If zoomed in, force full-res image instead of low-res preview - + use_editor_preview = ( self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index and not self.app_controller.ui_state.isZoomed and self.app_controller._last_rendered_preview is not None - and getattr(self.app_controller, "_last_rendered_preview_index", None) == index - and (gen is None or getattr(self.app_controller, "_last_rendered_preview_gen", None) == gen) + and getattr(self.app_controller, "_last_rendered_preview_index", None) + == index + and ( + gen is None + or getattr(self.app_controller, "_last_rendered_preview_gen", None) + == gen + ) ) image_data = ( - self.app_controller._last_rendered_preview - if use_editor_preview + self.app_controller._last_rendered_preview + if use_editor_preview else self.app_controller.get_decoded_image(index) ) if image_data: # Handle format being None (from prefetcher) or missing - fmt = getattr(image_data, 'format', None) + fmt = getattr(image_data, "format", None) if fmt is None: fmt = QImage.Format.Format_RGB888 @@ -74,24 +82,28 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: image_data.width, image_data.height, image_data.bytes_per_line, - fmt + fmt, ) - # Detach from Python buffer to prevent ownership issues and force proper texture upload # OPTIMIZATION: Only do this expensive copy when serving the live editor preview, # where we need to detach from the shared memory buffer that might change. # For standard browsing/prefetch, the buffer is stable enough. - if self.app_controller.ui_state.isEditorOpen and index == self.app_controller.current_index: + if ( + self.app_controller.ui_state.isEditorOpen + and index == self.app_controller.current_index + ): qimg = qimg.copy() else: # SAFETY: Keep a reference to the underlying buffer to prevent garbage collection # while Qt holds the QImage. QImage created from bytes does NOT own the data. - self._keepalive.append(image_data.buffer) + # Lock protects against concurrent access from QML rendering threads. + with self._keepalive_lock: + self._keepalive.append(image_data.buffer) # Set sRGB color space for proper color management (if available) # Skip this when using ICC mode - pixels are already in monitor space - color_mode = config.get('color', 'mode', fallback="none").lower() + color_mode = config.get("color", "mode", fallback="none").lower() if HAS_COLOR_SPACE and color_mode != "icc": try: # Create sRGB color space using constructor with NamedColorSpace enum @@ -101,8 +113,10 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: except (RuntimeError, ValueError) as e: log.warning(f"Failed to set color space: {e}") elif color_mode == "icc": - log.debug("ICC mode: skipping Qt color space (pixels already in monitor space)") - + log.debug( + "ICC mode: skipping Qt color space (pixels already in monitor space)" + ) + # Buffer is now safe to release (handled by copy), but original_buffer ref in Python object stays # We don't need to manually attach original_buffer to qimg anymore since we copied. return qimg @@ -123,15 +137,23 @@ class UIState(QObject): metadataChanged = Signal() themeChanged = Signal() preloadingStateChanged = Signal() + preloadingStateChanged = Signal() preloadProgressChanged = Signal() + + # Recycle Bin Signals + recycleBinStatsTextChanged = Signal() + hasRecycleBinItemsChanged = Signal() + isZoomedChanged = Signal() - statusMessageChanged = Signal() # New signal for status messages - resetZoomPanRequested = Signal() # Signal to tell QML to reset zoom/pan - absoluteZoomRequested = Signal(float) # New: Request absolute zoom level (1.0, 2.0, etc.) - stackSummaryChanged = Signal() # Signal for stack summary updates - filterStringChanged = Signal() # Signal for filter string updates - colorModeChanged = Signal() # Signal for color mode updates - saturationFactorChanged = Signal() # Signal for saturation factor updates + statusMessageChanged = Signal() # New signal for status messages + resetZoomPanRequested = Signal() # Signal to tell QML to reset zoom/pan + absoluteZoomRequested = Signal( + float + ) # New: Request absolute zoom level (1.0, 2.0, etc.) + stackSummaryChanged = Signal() # Signal for stack summary updates + filterStringChanged = Signal() # Signal for filter string updates + colorModeChanged = Signal() # Signal for color mode updates + saturationFactorChanged = Signal() # Signal for saturation factor updates awbModeChanged = Signal() awbStrengthChanged = Signal() awbWarmBiasChanged = Signal() @@ -141,13 +163,16 @@ class UIState(QObject): awbRgbLowerBoundChanged = Signal() awbRgbUpperBoundChanged = Signal() default_directory_changed = Signal(str) - isStackedJpgChanged = Signal() # New signal for isStackedJpg + currentDirectoryChanged = Signal() # Signal when working directory changes + isStackedJpgChanged = Signal() # New signal for isStackedJpg autoLevelClippingThresholdChanged = Signal(float) autoLevelStrengthChanged = Signal(float) autoLevelStrengthAutoChanged = Signal(bool) # Image Editor Signals is_editor_open_changed = Signal(bool) - editorImageChanged = Signal() # New signal for when the image loaded in editor changes + editorImageChanged = ( + Signal() + ) # New signal for when the image loaded in editor changes is_cropping_changed = Signal(bool) is_histogram_visible_changed = Signal(bool) @@ -160,7 +185,9 @@ class UIState(QObject): white_balance_mg_changed = Signal(float) aspect_ratio_names_changed = Signal(list) current_aspect_ratio_index_changed = Signal(int) - current_crop_box_changed = Signal(tuple) # (left, top, right, bottom) normalized to 0-1000 + current_crop_box_changed = Signal( + tuple + ) # (left, top, right, bottom) normalized to 0-1000 crop_rotation_changed = Signal(float) anySliderPressedChanged = Signal(bool) sharpness_changed = Signal(float) @@ -174,15 +201,15 @@ class UIState(QObject): whites_changed = Signal(float) clarity_changed = Signal(float) texture_changed = Signal(float) - + # Debug Cache Signals debugCacheChanged = Signal(bool) cacheStatsChanged = Signal(str) isDecodingChanged = Signal(bool) - debugModeChanged = Signal(bool) # General debug mode signal - isDialogOpenChanged = Signal(bool) # New signal for dialog state - editSourceModeChanged = Signal(str) # Notify when JPEG/RAW mode changes - saveBehaviorMessageChanged = Signal() # Signal for save behavior message updates + debugModeChanged = Signal(bool) # General debug mode signal + isDialogOpenChanged = Signal(bool) # New signal for dialog state + editSourceModeChanged = Signal(str) # Notify when JPEG/RAW mode changes + saveBehaviorMessageChanged = Signal() # Signal for save behavior message updates def __init__(self, app_controller): super().__init__() @@ -191,7 +218,7 @@ def __init__(self, app_controller): self._preload_progress = 0 # 1 = light, 0 = dark (controller will overwrite this on startup) self._theme = 1 - self._status_message = "" # New private variable for status message + self._status_message = "" # New private variable for status message # Image Editor State self._is_editor_open = False self._is_cropping = False @@ -211,7 +238,7 @@ def __init__(self, app_controller): "4:5 (Portrait)", "1.91:1 (Landscape)", "16:9 (Wide)", - "9:16 (Story)" + "9:16 (Story)", ] self._current_aspect_ratio_index = 0 self._any_slider_pressed = False @@ -226,22 +253,28 @@ def __init__(self, app_controller): self._whites = 0.0 self._clarity = 0.0 self._texture = 0.0 - + # Debug Cache State self._debug_cache = False self._cache_stats = "" self._is_decoding = False self._is_dialog_open = False - + # Connect to controller's dialog state signal self.app_controller.dialogStateChanged.connect(self._on_dialog_state_changed) - + # Connect to controller's mode change signal # We need to ensure the signal exists on controller first (it does, I added it) - if hasattr(self.app_controller, 'editSourceModeChanged'): - self.app_controller.editSourceModeChanged.connect(self.editSourceModeChanged) - self.app_controller.editSourceModeChanged.connect(lambda _: self.saveBehaviorMessageChanged.emit()) - self.app_controller.editSourceModeChanged.connect(lambda _: self.metadataChanged.emit()) # Also update metadata binding if needed + if hasattr(self.app_controller, "editSourceModeChanged"): + self.app_controller.editSourceModeChanged.connect( + self.editSourceModeChanged + ) + self.app_controller.editSourceModeChanged.connect( + lambda _: self.saveBehaviorMessageChanged.emit() + ) + self.app_controller.editSourceModeChanged.connect( + lambda _: self.metadataChanged.emit() + ) # Also update metadata binding if needed def _on_dialog_state_changed(self, is_open: bool): self.isDialogOpen = is_open @@ -330,31 +363,31 @@ def stackInfoText(self): if not self.app_controller.image_files: return "" return self.app_controller.get_current_metadata().get("stack_info_text", "") - + @Property(bool, notify=metadataChanged) def isUploaded(self): if not self.app_controller.image_files: return False return self.app_controller.get_current_metadata().get("uploaded", False) - + @Property(str, notify=metadataChanged) def uploadedDate(self): if not self.app_controller.image_files: return "" return self.app_controller.get_current_metadata().get("uploaded_date", "") - + @Property(str, notify=metadataChanged) def batchInfoText(self): if not self.app_controller.image_files: return "" return self.app_controller.get_current_metadata().get("batch_info_text", "") - + @Property(bool, notify=metadataChanged) def isEdited(self): if not self.app_controller.image_files: return False return self.app_controller.get_current_metadata().get("edited", False) - + @Property(str, notify=metadataChanged) def editedDate(self): if not self.app_controller.image_files: @@ -366,7 +399,7 @@ def isRestacked(self): if not self.app_controller.image_files: return False return self.app_controller.get_current_metadata().get("restacked", False) - + @Property(str, notify=metadataChanged) def restackedDate(self): if not self.app_controller.image_files: @@ -377,29 +410,39 @@ def restackedDate(self): @Property(bool, notify=metadataChanged) def hasRaw(self): - if not self.app_controller.image_files or self.app_controller.current_index >= len(self.app_controller.image_files): + if ( + not self.app_controller.image_files + or self.app_controller.current_index >= len(self.app_controller.image_files) + ): return False - return self.app_controller.image_files[self.app_controller.current_index].has_raw + return self.app_controller.image_files[ + self.app_controller.current_index + ].has_raw @Property(bool, notify=metadataChanged) def hasWorkingTif(self): - if not self.app_controller.image_files or self.app_controller.current_index >= len(self.app_controller.image_files): + if ( + not self.app_controller.image_files + or self.app_controller.current_index >= len(self.app_controller.image_files) + ): return False - return self.app_controller.image_files[self.app_controller.current_index].has_working_tif + return self.app_controller.image_files[ + self.app_controller.current_index + ].has_working_tif @Slot() def enableRawEditing(self): """Switches to RAW editing mode.""" - if hasattr(self.app_controller, 'enable_raw_editing'): + if hasattr(self.app_controller, "enable_raw_editing"): self.app_controller.enable_raw_editing() @Property(bool, notify=editSourceModeChanged) def isRawActive(self): """Returns True if the editor is in RAW source mode.""" - if hasattr(self.app_controller, 'current_edit_source_mode'): + if hasattr(self.app_controller, "current_edit_source_mode"): return self.app_controller.current_edit_source_mode == "raw" return False - + @Slot(result=bool) def load_image_for_editing(self): """Loads the currently viewed image into the editor.""" @@ -410,7 +453,6 @@ def developRaw(self): # Legacy support self.app_controller.develop_raw_for_current_image() - @Property(str, notify=stackSummaryChanged) def stackSummary(self): if not self.app_controller.stacks: @@ -418,15 +460,15 @@ def stackSummary(self): summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" for i, (start, end) in enumerate(self.app_controller.stacks): count = end - start + 1 - summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" + summary += f"Stack {i + 1}: {count} photos (indices {start}-{end})\n" return summary @Property(str, notify=saveBehaviorMessageChanged) def saveBehaviorMessage(self): """Returns a string describing what files will be affected by saving.""" - if not hasattr(self.app_controller, 'current_edit_source_mode'): + if not hasattr(self.app_controller, "current_edit_source_mode"): return "" - + if self.app_controller.current_edit_source_mode == "raw": return "Editing: RAW (writes working .tif + creates -developed.jpg; original JPG untouched)" else: @@ -529,7 +571,7 @@ def awbRgbUpperBound(self, value: int): self.app_controller.set_awb_rgb_upper_bound(value) self.awbRgbUpperBoundChanged.emit() - @Property(str, constant=True) + @Property(str, notify=currentDirectoryChanged) def currentDirectory(self): """Returns the path of the current working directory.""" return str(self.app_controller.image_dir) @@ -553,7 +595,6 @@ def nextImage(self): def prevImage(self): self.app_controller.prev_image() - @Slot() def launch_helicon(self): self.app_controller.launch_helicon() @@ -601,7 +642,7 @@ def check_path_exists(self, path): @Slot(result=float) def get_cache_size(self): return self.app_controller.get_cache_size() - + @Slot(result=float) def get_cache_usage_gb(self): return self.app_controller.get_cache_usage_gb() @@ -631,7 +672,7 @@ def set_theme(self, theme_index): @Slot(result=str) def get_default_directory(self): return self.app_controller.get_default_directory() - + @Slot(str) def set_default_directory(self, path): self.app_controller.set_default_directory(path) @@ -639,7 +680,7 @@ def set_default_directory(self, path): @Slot(result=str) def get_optimize_for(self): return self.app_controller.get_optimize_for() - + @Slot(str) def set_optimize_for(self, optimize_for): self.app_controller.set_optimize_for(optimize_for) @@ -679,7 +720,6 @@ def autoLevelStrengthAuto(self, value): def open_folder(self): self.app_controller.open_folder() - @Slot() def preloadAllImages(self): self.app_controller.preload_all_images() @@ -707,7 +747,7 @@ def resetZoomPan(self): @Property(bool, notify=is_editor_open_changed) def isEditorOpen(self) -> bool: return self._is_editor_open - + @isEditorOpen.setter def isEditorOpen(self, new_value: bool): if self._is_editor_open != new_value: @@ -747,7 +787,7 @@ def isDialogOpen(self, new_value: bool): @Property(bool, notify=anySliderPressedChanged) def anySliderPressed(self): return self._any_slider_pressed - + @anySliderPressed.setter def anySliderPressed(self, value): if self._any_slider_pressed != value: @@ -761,17 +801,17 @@ def setAnySliderPressed(self, pressed: bool): @Property(bool, notify=is_cropping_changed) def isCropping(self) -> bool: return self._is_cropping - + @isCropping.setter def isCropping(self, new_value: bool): if self._is_cropping != new_value: self._is_cropping = new_value self.is_cropping_changed.emit(new_value) - + @Property(bool, notify=is_histogram_visible_changed) def isHistogramVisible(self) -> bool: return self._is_histogram_visible - + @isHistogramVisible.setter def isHistogramVisible(self, new_value: bool): if self._is_histogram_visible != new_value: @@ -806,19 +846,19 @@ def reset_editor_state(self): self.cropRotation = 0.0 self.currentCropBox = (0, 0, 1000, 1000) self.currentAspectRatioIndex = 0 - - @Property('QVariant', notify=histogram_data_changed) + + @Property("QVariant", notify=histogram_data_changed) def histogramData(self): """Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values.""" return self._histogram_data - + @histogramData.setter def histogramData(self, new_value): if self._histogram_data != new_value: self._histogram_data = new_value self.histogram_data_changed.emit() - @Property('QVariant', notify=highlightStateChanged) + @Property("QVariant", notify=highlightStateChanged) def highlightState(self): """Returns highlight analysis state for UI display. @@ -829,18 +869,22 @@ def highlightState(self): """ editor = self.app_controller.image_editor state = {} - if editor and hasattr(editor, '_last_highlight_state') and editor._last_highlight_state: + if ( + editor + and hasattr(editor, "_last_highlight_state") + and editor._last_highlight_state + ): # Quick copy under lock to minimize contention # Using the editor's lock ensures we don't read while it's being written with editor._lock: state = dict(editor._last_highlight_state) - + # Normalize for QML robustness: ensure stable keys exist regardless of internal naming # Normalize for QML robustness: ensure stable keys exist return { - 'headroom_pct': state.get('headroom_pct', 0.0), - 'source_clipped_pct': state.get('source_clipped_pct', 0.0), - 'current_nearwhite_pct': state.get('current_nearwhite_pct', 0.0) + "headroom_pct": state.get("headroom_pct", 0.0), + "source_clipped_pct": state.get("source_clipped_pct", 0.0), + "current_nearwhite_pct": state.get("current_nearwhite_pct", 0.0), } @Property(float, notify=brightness_changed) @@ -892,25 +936,25 @@ def whiteBalanceMG(self, new_value: float): if self._white_balance_mg != new_value: self._white_balance_mg = new_value self.white_balance_mg_changed.emit(new_value) - + # Snake_case aliases for QML bracket notation access @Property(float, notify=white_balance_by_changed) def white_balance_by(self) -> float: return self._white_balance_by - + @white_balance_by.setter def white_balance_by(self, new_value: float): self.whiteBalanceBY = new_value - + @Property(float, notify=white_balance_mg_changed) def white_balance_mg(self) -> float: return self._white_balance_mg - + @white_balance_mg.setter def white_balance_mg(self, new_value: float): self.whiteBalanceMG = new_value - @Property('QVariantList', notify=aspect_ratio_names_changed) + @Property("QVariantList", notify=aspect_ratio_names_changed) def aspectRatioNames(self) -> list: return self._aspect_ratio_names @@ -930,7 +974,7 @@ def currentAspectRatioIndex(self, new_value: int): self._current_aspect_ratio_index = new_value self.current_aspect_ratio_index_changed.emit(new_value) - @Property('QVariant', notify=current_crop_box_changed) + @Property("QVariant", notify=current_crop_box_changed) def currentCropBox(self) -> tuple: # QML will receive this as a list return self._current_crop_box @@ -940,7 +984,7 @@ def currentCropBox(self, new_value): # Convert QJSValue or list to tuple if needed original_value = new_value try: - if hasattr(new_value, 'toVariant'): + if hasattr(new_value, "toVariant"): # It's a QJSValue, convert to tuple variant = new_value.toVariant() if isinstance(variant, (list, tuple)): @@ -967,13 +1011,18 @@ def currentCropBox(self, new_value): or len(new_value) != 4 or not all(isinstance(v, (int, float)) for v in new_value) ): - log.warning("UIState.currentCropBox: ignoring invalid crop box %r", new_value) - return + log.warning( + "UIState.currentCropBox: ignoring invalid crop box %r", new_value + ) + return if self._current_crop_box != new_value: self._current_crop_box = new_value self.current_crop_box_changed.emit(new_value) # Sync with ImageEditor - if hasattr(self.app_controller, 'image_editor') and self.app_controller.image_editor: + if ( + hasattr(self.app_controller, "image_editor") + and self.app_controller.image_editor + ): self.app_controller.image_editor.set_crop_box(new_value) @Property(float, notify=crop_rotation_changed) @@ -985,7 +1034,7 @@ def cropRotation(self, new_value: float): if self._crop_rotation != new_value: self._crop_rotation = new_value self.crop_rotation_changed.emit(new_value) - + # --- New Properties --- @Property(float, notify=sharpness_changed) def sharpness(self) -> float: @@ -1036,11 +1085,11 @@ def shadows(self, new_value: float): if self._shadows != new_value: self._shadows = new_value self.shadows_changed.emit(new_value) - + @Property(float, notify=vibrance_changed) def vibrance(self) -> float: return self._vibrance - + @vibrance.setter def vibrance(self, new_value: float): if self._vibrance != new_value: @@ -1056,11 +1105,11 @@ def vignette(self, new_value: float): if self._vignette != new_value: self._vignette = new_value self.vignette_changed.emit(new_value) - + @Property(float, notify=blacks_changed) def blacks(self) -> float: return self._blacks - + @blacks.setter def blacks(self, new_value: float): if self._blacks != new_value: @@ -1080,7 +1129,7 @@ def whites(self, new_value: float): @Property(float, notify=clarity_changed) def clarity(self) -> float: return self._clarity - + @clarity.setter def clarity(self, new_value: float): if self._clarity != new_value: @@ -1090,7 +1139,7 @@ def clarity(self, new_value: float): @Property(float, notify=texture_changed) def texture(self) -> float: return self._texture - + @texture.setter def texture(self, new_value: float): if self._texture != new_value: @@ -1147,84 +1196,140 @@ def debugMode(self, value: bool): isGridViewActiveChanged = Signal(bool) gridDirectoryChanged = Signal(str) gridSelectedCountChanged = Signal() # No args - QML property notify pattern + gridScrollToIndex = Signal(int) # Scroll grid view to show this index + gridCanGoBackChanged = Signal() # Emitted when back history changes @Property(bool, notify=isGridViewActiveChanged) def isGridViewActive(self) -> bool: """Returns True if grid view is active, False for loupe view.""" - return getattr(self.app_controller, '_is_grid_view_active', False) + return getattr(self.app_controller, "_is_grid_view_active", False) @isGridViewActive.setter def isGridViewActive(self, value: bool): # Use controller method to ensure side effects (model refresh, resolver update) are applied - if hasattr(self.app_controller, '_set_grid_view_active'): + if hasattr(self.app_controller, "_set_grid_view_active"): self.app_controller._set_grid_view_active(value) @Property(str, notify=gridDirectoryChanged) def gridDirectory(self) -> str: """Returns the current directory shown in grid view.""" - if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + if ( + hasattr(self.app_controller, "_thumbnail_model") + and self.app_controller._thumbnail_model + ): return str(self.app_controller._thumbnail_model.current_directory) return str(self.app_controller.image_dir) @Property(int, notify=gridSelectedCountChanged) def gridSelectedCount(self) -> int: """Returns count of selected items in grid view (efficient, no list copy).""" - if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + if ( + hasattr(self.app_controller, "_thumbnail_model") + and self.app_controller._thumbnail_model + ): return self.app_controller._thumbnail_model.selected_count return 0 @Slot() def toggleGridView(self): """Toggle between grid view and loupe view.""" - if hasattr(self.app_controller, 'toggle_grid_view'): + if hasattr(self.app_controller, "toggle_grid_view"): self.app_controller.toggle_grid_view() @Slot(int) def gridOpenIndex(self, index: int): """Open an image from grid view in loupe view.""" - if hasattr(self.app_controller, 'grid_open_index'): + if hasattr(self.app_controller, "grid_open_index"): self.app_controller.grid_open_index(index) @Slot(str) def gridNavigateTo(self, path: str): """Navigate to a folder in grid view.""" - if hasattr(self.app_controller, 'grid_navigate_to'): + if hasattr(self.app_controller, "grid_navigate_to"): self.app_controller.grid_navigate_to(path) @Slot() def gridClearSelection(self): """Clear all selections in grid view.""" - if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + if ( + hasattr(self.app_controller, "_thumbnail_model") + and self.app_controller._thumbnail_model + ): self.app_controller._thumbnail_model.clear_selection() @Slot(int, bool, bool) def gridSelectIndex(self, index: int, shift: bool, ctrl: bool): """Handle selection at index with modifier keys.""" - if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + if ( + hasattr(self.app_controller, "_thumbnail_model") + and self.app_controller._thumbnail_model + ): self.app_controller._thumbnail_model.select_index(index, shift, ctrl) - @Slot(result='QVariantList') + @Slot(result="QVariantList") def gridGetSelectedPaths(self) -> list: """Get list of selected image paths in grid view.""" - if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: - return [str(p) for p in self.app_controller._thumbnail_model.get_selected_paths()] + if ( + hasattr(self.app_controller, "_thumbnail_model") + and self.app_controller._thumbnail_model + ): + return [ + str(p) + for p in self.app_controller._thumbnail_model.get_selected_paths() + ] return [] @Slot() def gridRefresh(self): """Refresh the grid view.""" - if hasattr(self.app_controller, '_thumbnail_model') and self.app_controller._thumbnail_model: + if ( + hasattr(self.app_controller, "_thumbnail_model") + and self.app_controller._thumbnail_model + ): self.app_controller._thumbnail_model.refresh() # Also update path resolver - if hasattr(self.app_controller, '_path_resolver'): - self.app_controller._path_resolver.update_from_model(self.app_controller._thumbnail_model) + if hasattr(self.app_controller, "_path_resolver"): + self.app_controller._path_resolver.update_from_model( + self.app_controller._thumbnail_model + ) + + @Property(bool, notify=gridCanGoBackChanged) + def gridCanGoBack(self) -> bool: + """Returns True if there's navigation history to go back to.""" + if hasattr(self.app_controller, "_grid_nav_history"): + return len(self.app_controller._grid_nav_history) > 0 + return False + + @Slot() + def gridGoBack(self): + """Navigate back to the previous directory in grid view.""" + if hasattr(self.app_controller, "grid_go_back"): + self.app_controller.grid_go_back() + + @Slot() + def gridAddSelectionToBatch(self): + """Add grid-selected images to batch.""" + if hasattr(self.app_controller, "grid_add_selection_to_batch"): + self.app_controller.grid_add_selection_to_batch() + + @Slot(int) + def gridDeleteAtCursor(self, cursorIndex: int): + """Delete image(s) from grid view - selection or cursor image.""" + if hasattr(self.app_controller, "grid_delete_at_cursor"): + self.app_controller.grid_delete_at_cursor(cursorIndex) @Slot(int, int) def gridPrefetchRange(self, startIndex: int, endIndex: int): """Prefetch thumbnails for the given index range.""" - if not hasattr(self.app_controller, '_thumbnail_model') or not self.app_controller._thumbnail_model: + if ( + not hasattr(self.app_controller, "_thumbnail_model") + or not self.app_controller._thumbnail_model + ): return - if not hasattr(self.app_controller, '_thumbnail_prefetcher') or not self.app_controller._thumbnail_prefetcher: + if ( + not hasattr(self.app_controller, "_thumbnail_prefetcher") + or not self.app_controller._thumbnail_prefetcher + ): return model = self.app_controller._thumbnail_model @@ -1240,3 +1345,30 @@ def gridPrefetchRange(self, startIndex: int, endIndex: int): if entry and not entry.is_folder: prefetcher.submit(entry.path, entry.mtime_ns) + @Property(str, notify=recycleBinStatsTextChanged) + def recycleBinStatsText(self): + """Returns a formatted string of recycle bin stats.""" + stats = self.app_controller.get_recycle_bin_stats() + if not stats: + return "" + + summary = "The following recycle bins contain items:\n\n" + for item in stats: + summary += f"• {item['path']}: {item['count']} files\n" + + summary += "\nDo you want to delete them before quitting?" + return summary + + @Property(bool, notify=hasRecycleBinItemsChanged) + def hasRecycleBinItems(self): + """Returns True if there are items in any recycle bin.""" + stats = self.app_controller.get_recycle_bin_stats() + return len(stats) > 0 + + @Slot() + def cleanupRecycleBins(self): + """Deletes all tracked recycle bins.""" + self.app_controller.cleanup_recycle_bins() + + self.recycleBinStatsTextChanged.emit() + self.hasRecycleBinItemsChanged.emit() diff --git a/faststack/verify_wb.py b/faststack/verify_wb.py index 96d352d..ffd0573 100644 --- a/faststack/verify_wb.py +++ b/faststack/verify_wb.py @@ -1,34 +1,34 @@ - import numpy as np from PIL import Image from faststack.imaging.editor import ImageEditor import os + def test_white_balance(): editor = ImageEditor() - + # 1. Test Black Preservation # Create a purely black image - black_img = Image.new('RGB', (100, 100), (0, 0, 0)) + black_img = Image.new("RGB", (100, 100), (0, 0, 0)) black_path = "test_black.jpg" black_img.save(black_path) - + editor.load_image(black_path) - + # Apply strong temperature and tint - editor.set_edit_param('white_balance_by', 1.0) # Max Warm - editor.set_edit_param('white_balance_mg', 1.0) # Max Magenta - + editor.set_edit_param("white_balance_by", 1.0) # Max Warm + editor.set_edit_param("white_balance_mg", 1.0) # Max Magenta + # Get processed image # We need to access the internal method or use save, but let's use _apply_edits directly for testing # editor.original_image is loaded. processed_img = editor._apply_edits(editor.original_image.copy()) arr = np.array(processed_img) - + # Check max value - should still be 0 or very close to it max_val = arr.max() print(f"Black Image Max Value after WB: {max_val}") - + if max_val > 0: print("FAIL: Black level not preserved!") else: @@ -36,20 +36,20 @@ def test_white_balance(): # 2. Test Grey Shift # Create a mid-grey image - grey_img = Image.new('RGB', (100, 100), (128, 128, 128)) + grey_img = Image.new("RGB", (100, 100), (128, 128, 128)) grey_path = "test_grey.jpg" grey_img.save(grey_path) - + editor.load_image(grey_path) - editor.set_edit_param('white_balance_by', 0.5) # Warm + editor.set_edit_param("white_balance_by", 0.5) # Warm # r_gain = 1 + 0.25 = 1.25 -> 128 * 1.25 = 160 # b_gain = 1 - 0.25 = 0.75 -> 128 * 0.75 = 96 - + processed_img = editor._apply_edits(editor.original_image.copy()) arr = np.array(processed_img) - r, g, b = arr[0,0] + r, g, b = arr[0, 0] print(f"Grey Image RGB after Warm shift: R={r}, G={g}, B={b}") - + if r > 128 and b < 128: print("PASS: Grey shifted warm correctly.") else: @@ -62,5 +62,6 @@ def test_white_balance(): except OSError: pass # File may not exist or be locked + if __name__ == "__main__": test_white_balance() diff --git a/pyproject.toml b/pyproject.toml index f615b6e..39afe2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,4 +56,5 @@ testpaths = ["faststack/tests"] python_files = ["test_*.py"] addopts = "-p no:cacheprovider -p no:doctest --basetemp=./var/pytest-temp" norecursedirs = ["var", ".venv", "cache", "faststack.egg-info", "__pycache__"] +pythonpath = ["."]