From b7a74b1676cce4f27ab40aaea59d8fbeeb6bf587 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Wed, 4 Feb 2026 23:31:20 -0500 Subject: [PATCH 1/5] Fixed free rotate and image delete issues --- ChangeLog.md | 28 +- README.md | 2 +- faststack/app.py | 906 ++++++++++--------- faststack/final_all_fail.txt | Bin 0 -> 8860 bytes faststack/imaging/editor.py | 64 +- faststack/imaging/math_utils.py | 26 +- faststack/imaging/metadata.py | 4 +- faststack/imaging/prefetch.py | 18 +- faststack/io/deletion.py | 1 + faststack/io/indexer.py | 6 +- faststack/loupe_fail.txt | Bin 0 -> 8158 bytes faststack/qml/Components.qml | 4 + faststack/qml/ImageEditorDialog.qml | 12 +- faststack/qml/Main.qml | 138 ++- faststack/qml/ThumbnailGridView.qml | 2 +- faststack/test_fail_out.txt | Bin 0 -> 7464 bytes faststack/test_final_fail.txt | Bin 0 -> 1050 bytes faststack/test_loupe_out.txt | Bin 0 -> 8646 bytes faststack/test_results_debug.txt | Bin 0 -> 35358 bytes faststack/test_results_debug_2.txt | Bin 0 -> 35358 bytes faststack/test_results_debug_3.txt | Bin 0 -> 12004 bytes faststack/test_unif_out.txt | Bin 0 -> 1050 bytes faststack/tests/test_deletion_unification.py | 329 +++++++ faststack/tests/test_highlight_recovery.py | 65 +- faststack/tests/test_loupe_delete.py | 144 +++ faststack/tests/test_reactive_delete.py | 70 +- faststack/ui/provider.py | 58 +- pyproject.toml | 2 +- 28 files changed, 1355 insertions(+), 524 deletions(-) create mode 100644 faststack/final_all_fail.txt create mode 100644 faststack/loupe_fail.txt create mode 100644 faststack/test_fail_out.txt create mode 100644 faststack/test_final_fail.txt create mode 100644 faststack/test_loupe_out.txt create mode 100644 faststack/test_results_debug.txt create mode 100644 faststack/test_results_debug_2.txt create mode 100644 faststack/test_results_debug_3.txt create mode 100644 faststack/test_unif_out.txt create mode 100644 faststack/tests/test_deletion_unification.py create mode 100644 faststack/tests/test_loupe_delete.py diff --git a/ChangeLog.md b/ChangeLog.md index 5532fc1..0e37789 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,6 +1,32 @@ # ChangeLog -Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. +Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. + +## 1.5.4 (2026-02-04) + +### Fixed +- Image rotation fixed - no more black wedges on the edges of the image. +- Prevented “undo delete” from resurrecting files when recycle/rollback fails: if a JPG can’t be restored after a partial delete, it’s marked as deleted (`jpg_moved=True`), a warning is shown, and a `recycled_jpg_path` breadcrumb is stored for potential cleanup. +- Improved crop behavior when straightening/rotating with `expand=True` by transforming crop coordinates from original image space into the expanded canvas. +- Prevented exporting with stale preview-resolution blur caches by validating cached array shapes against the current Y channel dimensions. +- Improved highlight recovery by switching to an adaptive rational compression shoulder (new `k` parameter) and added tests for identity-at-zero, pivot invariance, and increasing compression with higher amount. +- Fixed QML empty-state message timing by only showing “No images in this folder” after the folder has been scanned at least once. +- Improved Escape key reliability during crop/rotation by explicitly re-focusing the loupe view. + +### Changed +- Refactored deletion into a unified core deletion engine (`_delete_indices`) shared by loupe, grid cursor, grid selection, and batch deletion paths. +- Deletion now uses an optimistic UI update for instant feedback, with deferred/coalesced disk refresh to avoid flicker and “deleted items reappear” issues. +- Grid deletion now supports multi-selection and cursor deletion through a single entry point, rebuilding the path→index mapping for reliable lookup. +- Image saving is now offloaded to a background thread to keep the UI responsive: + - Added an `isSaving` state to disable Save actions and show “Saving…” feedback. + - Prevented “surprise close” by only auto-closing the editor if the user is still on the same image when the save completes. +- Improved recycle-bin cleanup on quit: + - Replaced the simple message dialog with a richer dialog showing per-bin counts (JPG/RAW/other) and an optional detailed file list. + +### UI +- Resized the Image Editor dialog to accommodate the saving state/controls. +- Enhanced recycle bin cleanup dialog layout and interaction (expandable detailed list, clearer button actions). + ## 1.5.3 (2026-01-27) diff --git a/README.md b/README.md index 8551109..99aa10b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 1.5 - January 1, 2026 +# Version 1.5.4 - February 4, 2026 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. diff --git a/faststack/app.py b/faststack/app.py index 62381f1..43da7f3 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -77,6 +77,11 @@ ) import numpy as np from faststack.io.indexer import RAW_EXTENSIONS +from faststack.io.deletion import ( + confirm_permanent_delete, + confirm_batch_permanent_delete, + permanently_delete_image_files, +) def make_hdrop(paths): @@ -121,6 +126,9 @@ class ProgressReporter(QObject): finished = Signal() editSourceModeChanged = Signal(str) # Notify when JPEG/RAW mode changes + _saveFinished = Signal( + object + ) # Signal for save completion (result or error from background) def __init__( self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: bool = False @@ -135,6 +143,10 @@ def __init__( self.histogramReady.connect(self._apply_histogram_result) self.previewReady.connect(self._apply_preview_result) + # Save Offloading Setup (runs save_image in background thread) + self._save_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + self._saveFinished.connect(self._on_save_finished) + # Preview Offloading Setup self._preview_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) self._preview_inflight = False @@ -143,14 +155,15 @@ def __init__( self._preview_lock = threading.Lock() self._last_rendered_preview = None # Store latest valid render self._shutting_down = False # Flag to gate async callbacks during shutdown + self._refresh_scheduled = False # Coalesce guard for deferred disk refresh self._opencv_warning_shown = False # Only show OpenCV warning once per session self.image_dir = image_dir self.image_files: List[ImageFile] = [] # Filtered list for display self._all_images: List[ImageFile] = [] # Cached full list from disk - self._path_to_index: Dict[Path, int] = ( - {} - ) # Resolved path -> 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 @@ -208,9 +221,9 @@ def __init__( # -- 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._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, @@ -280,9 +293,9 @@ def __init__( 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)), ...] + self.delete_history: List[ + DeleteRecord + ] = [] # [((jpg_src, jpg_bin), (raw_src, raw_bin)), ...] # Track all undoable actions with timestamps # [(action_type, action_data, timestamp)] @@ -319,21 +332,7 @@ def __init__( # 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 - + # _move_to_recycle robust version is defined below in the deletion section @Slot(bool) def _on_editor_open_changed(self, is_open: bool): """Handle necessary setup/cleanup when editor opens or closes.""" @@ -567,13 +566,25 @@ def eventFilter(self, watched, event) -> bool: 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 + # Esc cancels crop mode if active (priority: before grid handling) + # Handled here because QML focus issues can prevent Keys.onEscapePressed from firing + if event.key() == Qt.Key_Escape and getattr( + self.ui_state, "isCropping", False ): + self.cancel_crop_mode() + return True # Consume event, crop mode cancelled + + # When editing, let QML handle Enter/Esc and related keys. + # Otherwise keybinder can swallow them before QML sees them. + if getattr(self.ui_state, "isEditorOpen", False): return False + # When cropping, let QML handle Enter/Return for crop execution + if getattr(self.ui_state, "isCropping", False): + key = event.key() + if key in (Qt.Key_Enter, Qt.Key_Return): + return False # Let QML handle crop execution + # When in grid view, let QML handle navigation and action keys if self._is_grid_view_active: key = event.key() @@ -647,6 +658,9 @@ def load(self, skip_thumbnail_refresh: bool = False): if self._is_grid_view_active and not skip_thumbnail_refresh: self._thumbnail_model.refresh() self._path_resolver.update_from_model(self._thumbnail_model) + # Mark folder as loaded so QML can show "No images" message if truly empty + self._folder_loaded = True + self.ui_state.isFolderLoadedChanged.emit() def refresh_image_list(self): """Rescans the directory for images from disk and updates cache. @@ -929,43 +943,93 @@ def sync_ui_state(self): @Slot() def save_edited_image(self): - """Saves functionality delegating to ImageEditor. - - Restores "Old" behavior: - - Save image - - Close Editor - - Clear Editor State - - Refresh List - - Re-select saved image + """Saves the edited image in a background thread to keep UI responsive. + + Sets isSaving=True, spawns background worker, returns immediately. + On completion, _on_save_finished is called via signal to perform cleanup. """ if not self.image_editor.original_image: return - # Only write developed sidecar when editing from RAW source + # Prevent double-saves + if self.ui_state.isSaving: + return + + # Capture state needed for save before we start write_sidecar = self.current_edit_source_mode == "raw" dev_path = None if write_sidecar and 0 <= self.current_index < len(self.image_files): 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 - ) - except RuntimeError as e: - self.update_status_message(str(e)) + # Store save token to prevent "surprise close" if user navigates away during save + self._save_initiated_path = self.image_editor.current_filepath + + # Show saving indicator + self.ui_state.isSaving = True + self.update_status_message("Saving...") + + # Submit save work to background thread + def do_save(): + """Worker function that runs in background thread.""" + try: + result = self.image_editor.save_image( + write_developed_jpg=write_sidecar, developed_path=dev_path + ) + return {"success": True, "result": result} + except RuntimeError as e: + return {"success": False, "error": str(e)} + except Exception as e: + log.exception(f"Unexpected error during save: {e}") + return {"success": False, "error": "Failed to save image"} + + def on_done(future): + """Callback when background save completes - emits signal to hop to main thread.""" + # Guard emit during shutdown to prevent signal to deleted QObject + if self._shutting_down: + return + try: + result = future.result() + except Exception as e: + result = {"success": False, "error": str(e)} + # Emit signal to process result on main thread + self._saveFinished.emit(result) + + future = self._save_executor.submit(do_save) + future.add_done_callback(on_done) + + @Slot(object) + def _on_save_finished(self, save_result: dict): + """Handle save completion on main thread (called via signal from background).""" + # Guard against callbacks during/after shutdown + if self._shutting_down: return - except Exception as e: - log.exception(f"Unexpected error during save: {e}") - self.update_status_message("Failed to save image") + + # Always clear saving indicator + self.ui_state.isSaving = False + + if not save_result.get("success"): + self.update_status_message(save_result.get("error", "Save failed")) return + result = save_result.get("result") if result: saved_path, _ = result # backup_path unused - # --- Restore Old Behavior --- + # --- Post-Save Cleanup --- - # 1. Close Editor UI - self.ui_state.isEditorOpen = False + # Only auto-close editor if still on the same image that initiated the save + # Prevents "surprise close" if user navigated away during save + initiated_path = getattr(self, "_save_initiated_path", None) + editor_still_on_same_image = ( + self.ui_state.isEditorOpen + and self.image_editor.current_filepath + and initiated_path + and self.image_editor.current_filepath == initiated_path + ) + + # 1. Close Editor UI (only if still on same image) + if editor_still_on_same_image: + self.ui_state.isEditorOpen = False # 2. Clear Editor State (release memory) self.image_editor.clear() @@ -1253,34 +1317,65 @@ 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. + @Slot() + def delete_current_image(self): + """Standard entry point for Loupe deletion. + Triggers batch dialog if current image is part of a multi-image batch. + """ + # 1. Check if we're in a multi-image batch + batch_count = self.get_batch_count_for_current_image() + if batch_count > 1 and self.main_window: + # Trigger QML batch deletion dialog (user confirms there) + self.main_window.show_delete_batch_dialog(batch_count) + return + + # 2. Otherwise default to single image deletion + self._delete_indices([self.current_index], "loupe") - If there are selected images, deletes all selected images. - If no selection, deletes the image at the cursor position. + @Slot(int) + def grid_delete_at_cursor(self, cursor_index: int): + """Unified grid deletion entry point. + Handles both multi-selection and single-cursor deletion. """ if not self._thumbnail_model: return - # Check if there are selections + # 1. Rebuild index mapping once for reliable lookup + self._rebuild_path_to_index() + + # 2. Prefer selection if it exists selected_paths = self._thumbnail_model.get_selected_paths() if selected_paths: - # Delete all selected images - self._delete_grid_selected_images(selected_paths) - return + indices = [] + for path in selected_paths: + idx = self._path_to_index.get(path.resolve()) + if idx is not None: + indices.append(idx) + + if not indices: + self.update_status_message("Selected images not found in current list.") + 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) + summary = self._delete_indices(indices, "grid_selection") + if summary["all_deleted"]: + self._thumbnail_model.clear_selection() return - if entry.is_folder: - self.update_status_message("Cannot delete folders") - return + # 3. Fallback to cursor index if no selection + if cursor_index >= 0: + entry = self._thumbnail_model.get_entry(cursor_index) + if not entry: + return + if entry.is_folder: + self.update_status_message("Cannot delete folders in grid view.") + return + + idx = self._path_to_index.get(entry.path.resolve()) + if idx is None: + self.update_status_message("Image not found in current list.") + return - # Delete this single image using its path - self._delete_grid_selected_images([entry.path]) + self._delete_indices([idx], "grid_cursor") def _on_thumbnail_ready(self, thumbnail_id: str): """Callback when a thumbnail finishes decoding (called from worker thread). @@ -2632,33 +2727,6 @@ def get_batch_count_for_current_image(self) -> int: 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: - # 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) - 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(): @@ -2695,135 +2763,296 @@ 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. + def _delete_indices(self, indices: List[int], action_type: str) -> dict: + """Unified core deletion engine for FastStack. - 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. + Uses optimistic UI pattern: updates in-memory list and UI immediately + for instant visual feedback, then performs file I/O synchronously. + If deletion fails or is cancelled, state is rolled back. + Heavy disk-scan refresh is deferred to after UI paint. Args: - image_file: The ImageFile to delete permanently. - reason: Reason for permanent deletion (e.g., "Recycle bin unavailable"). + indices: List of indices into self.image_files to delete. + action_type: String for logging (e.g. 'loupe', 'grid_selection', 'grid_cursor', 'batch'). Returns: - True if user confirms deletion, False if cancelled. + dict: { + "total_deleted": int, + "recycled": int, + "permanent": int, + "failed_recycles": list[ImageFile], + "cancelled": bool + } """ - from faststack.io.deletion import confirm_permanent_delete + from PySide6.QtCore import QTimer + + summary = { + "total_deleted": 0, + "recycled": 0, + "permanent": 0, + "failed_recycles": [], + "cancelled": False, + "requested_count": 0, # Updated after validation + "all_deleted": False, + } - return confirm_permanent_delete(image_file, reason) + if not self.image_files or not indices: + log.debug(f"[_delete_indices] Nothing to delete: action={action_type}") + return summary + + # 1. Collect ImageFile objects and sort indices in reverse to prevent shifting + sorted_indices = sorted(list(set(indices)), reverse=True) + images_to_delete = [] + for idx in sorted_indices: + if 0 <= idx < len(self.image_files): + images_to_delete.append(self.image_files[idx]) + + if not images_to_delete: + log.warning(f"[_delete_indices] No valid indices found in {indices}") + return summary + + # Update requested_count from validated list (not raw indices) + summary["requested_count"] = len(images_to_delete) + + # --- PHASE 1: OPTIMISTIC UI UPDATE (instant, no I/O) --- + # Snapshot for potential rollback (store in ascending order for proper restoration) + removed_items = [ + (idx, self.image_files[idx]) + for idx in sorted(sorted_indices) + if 0 <= idx < len(self.image_files) + ] + previous_index = self.current_index - def _confirm_batch_permanent_delete(self, images: list, reason: str = "") -> bool: - """Show a confirmation dialog for permanent deletion of multiple images. + # Remove from in-memory list immediately for instant visual feedback + for idx in sorted_indices: + if 0 <= idx < len(self.image_files): + del self.image_files[idx] - Args: - images: List of ImageFile objects to delete permanently. - reason: Reason for permanent deletion. + # Reposition current_index immediately (fast, in-memory only) + if not self.image_files: + self.current_index = 0 + else: + self.current_index = min(previous_index, len(self.image_files) - 1) - Returns: - True if user confirms deletion, False if cancelled. - """ - from faststack.io.deletion import confirm_batch_permanent_delete + # Update UI immediately - this is fast since it just reads from memory + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + if self.image_files: + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() - return confirm_batch_permanent_delete(images, reason) + # NOTE: Thumbnail model refresh is deferred to Phase 4 to avoid disk rescan + # while files are still in transit (prevents "deleted items reappear" flicker) + + # --- PHASE 2: SYNCHRONOUS FILE I/O (for correct undo/summary) --- + recycled_count = 0 + permanent_count = 0 + partial_fail_count = 0 + failed_recycles = [] + # Track per-image deletion status (resolved path -> {jpg_moved, raw_moved}) + # Use resolved paths for robustness against symbolic links or path variations + successfully_deleted = {} # resolved_path -> deletion status dict + timestamp = time.time() - def _permanently_delete_image_files(self, image_file) -> bool: - """Permanently delete an image and its RAW pair from disk. + for img in images_to_delete: + jpg_path = img.path + raw_path = img.raw_pair - This does NOT add to undo history since deletion is permanent. + 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 + ) - Args: - image_file: The ImageFile to delete. + if recycled_jpg: + # Check for partial failure: RAW existed but failed to move + raw_needed = raw_path and raw_path.exists() + raw_failed = raw_needed and not recycled_raw - Returns: - True if at least one file was deleted, False otherwise. - """ - from faststack.io.deletion import permanently_delete_image_files + if raw_failed: + # Atomic unit behavior: undo JPG move and treat as failed + log.warning( + f"Partial recycle for {img.path.name}: JPG ok, RAW failed. " + "Undoing JPG move to keep pair consistent." + ) + undo_succeeded = False + try: + # Move JPG back from recycle bin + import shutil + + shutil.move(str(recycled_jpg), str(jpg_path)) + log.info(f"Restored {jpg_path.name} from recycle bin") + undo_succeeded = True + except (OSError, shutil.Error) as undo_err: + log.error( + f"Failed to undo JPG move for {jpg_path.name}: {undo_err}" + ) + # Mark as deleted to prevent rollback from resurrecting missing image + resolved_key = img.path.resolve() + successfully_deleted[resolved_key] = { + "jpg_moved": True, # JPG is not in folder anymore + "raw_moved": False, # RAW still present + "undo_failed": True, + "recycled_jpg_path": recycled_jpg, # Breadcrumb for cleanup + } + self.update_status_message( + f"Warning: couldn't restore {jpg_path.name}; " + "file may be locked. RAW not deleted." + ) - return permanently_delete_image_files(image_file) + partial_fail_count += 1 + # Only add to failed_recycles if undo succeeded (JPG is back in folder) + # If undo failed, permanent delete can't act on it properly + if undo_succeeded: + failed_recycles.append(img) + else: + # Full success (JPG moved, and RAW either moved or didn't exist) + record = ((jpg_path, recycled_jpg), (raw_path, recycled_raw)) + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + recycled_count += 1 + # Use resolved path as key for robustness + resolved_key = img.path.resolve() + successfully_deleted[resolved_key] = { + "jpg_moved": True, + "raw_moved": recycled_raw is not None or not raw_needed, + } + else: + log.error(f"Failed to recycle JPG: {jpg_path.name}") + failed_recycles.append(img) + except (OSError, PermissionError) as e: + log.warning(f"Recycle exception for {jpg_path.name}: {e}") + failed_recycles.append(img) + + # Handle failed recycles with permanent delete fallback + if failed_recycles: + reason = "Recycle bin failure or insufficient permissions." + confirmed = False + if len(failed_recycles) == 1: + confirmed = confirm_permanent_delete(failed_recycles[0], reason=reason) + else: + confirmed = confirm_batch_permanent_delete( + failed_recycles, reason=reason + ) - 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 + if confirmed: + for img in failed_recycles: + if permanently_delete_image_files(img): + permanent_count += 1 + successfully_deleted[img.path.resolve()] = { + "jpg_moved": True, + "raw_moved": True, # Permanent delete removes both + } + else: + summary["cancelled"] = True + log.info( + f"Permanent deletion of {len(failed_recycles)} files cancelled by user." + ) - previous_index = self.current_index - image_file = self.image_files[index] - jpg_path = image_file.path - raw_path = image_file.raw_pair - - # 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 - ) + # Build summary + deleted_count = recycled_count + permanent_count + summary["total_deleted"] = deleted_count + summary["recycled"] = recycled_count + summary["permanent"] = permanent_count + summary["failed_recycles"] = failed_recycles + summary["all_deleted"] = deleted_count == summary["requested_count"] + + # --- ROLLBACK if deletion incomplete --- + # If cancelled or some files failed to delete, restore those items to the list + if summary["cancelled"] or deleted_count < summary["requested_count"]: + # Identify items to restore: only if JPG wasn't successfully deleted + # (prevents restoring ImageFile whose RAW is orphaned in recycle) + items_to_restore = [ + (idx, img) + for idx, img in removed_items + if img.path.resolve() not in successfully_deleted + or not successfully_deleted[img.path.resolve()].get("jpg_moved", False) + ] - # Add to delete history if anything was moved - if recycled_jpg or recycled_raw: - import time + if items_to_restore: + log.info( + f"Rolling back {len(items_to_restore)} items after incomplete deletion" + ) + # Restore items in ascending index order + for idx, img in items_to_restore: + # Clamp insertion index to valid range + insert_idx = min(idx, len(self.image_files)) + self.image_files.insert(insert_idx, img) - 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)) + # Restore previous index position + self.current_index = min(previous_index, len(self.image_files) - 1) - 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 + # Refresh UI to reflect rollback + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + if self.image_files: + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() - 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 + # --- PHASE 3: Status messages (immediate feedback) --- + if deleted_count > 0: + if permanent_count > 0: + msg = f"Permanently deleted {permanent_count} image(s)" + if recycled_count > 0: + msg += f" ({recycled_count} moved to recycle bin)" + self.update_status_message(msg) + elif recycled_count > 0: + if summary["cancelled"] and failed_recycles: + msg = f"Deleted {recycled_count} image(s); {len(failed_recycles)} could not be deleted (cancelled)" + elif partial_fail_count > 0: + msg = f"Deleted {recycled_count} images (some RAW pairs failed to recycle)" + else: + msg = ( + "Image moved to recycle bin" + if recycled_count == 1 + else f"Deleted {recycled_count} images" + ) + self.update_status_message(msg) - # Clear folder stats cache so recycle bin count updates - clear_raw_count_cache() + # Log completion + log.info( + f"Deletion complete: type='{action_type}', total_deleted={deleted_count}, " + f"recycled={recycled_count}, permanent={permanent_count}, " + f"partial_fails={partial_fail_count}, " + f"final_index={self.current_index}, list_len={len(self.image_files)}" + ) - # Refresh image list and move to next image - self.refresh_image_list() + # --- PHASE 4: DEFERRED DISK REFRESH (after UI paint) --- + # Schedule heavy disk operations for next event loop iteration + # Use coalescing guard to prevent multiple refreshes on rapid deletes + if not self._refresh_scheduled: + self._refresh_scheduled = True + + def do_deferred_refresh(): + self._refresh_scheduled = False + clear_raw_count_cache() + self.refresh_image_list() + self._rebuild_path_to_index() + # Now safe to refresh thumbnail model after disk state is consistent + if self._thumbnail_model: + self._thumbnail_model.refresh() + if hasattr(self, "_path_resolver"): + self._path_resolver.update_from_model(self._thumbnail_model) + + QTimer.singleShot(0, do_deferred_refresh) - # 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 - 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() + else: + if failed_recycles: + if summary["cancelled"]: + self.update_status_message("Deletion cancelled") + else: + self.update_status_message("Delete failed") + log.info( + f"Deletion Action '{action_type}' resulted in no changes (cancelled/failed)." + ) + else: + log.debug(f"Deletion Action '{action_type}' - nothing processed.") - self.update_status_message(status_msg) + return summary def _reposition_after_delete( self, preserved_path: Optional[Path], previous_index: int @@ -2844,248 +3073,38 @@ def _reposition_after_delete( @Slot() def delete_current_image_only(self): """Delete only the current image, ignoring batch selection.""" - if not self.image_files: - self.update_status_message("No image to delete.") - return - self._delete_single_image(self.current_index) + self._delete_indices([self.current_index], "loupe_single_only") @Slot() def delete_batch_images(self): - """Delete all images in the current batch.""" - if not self.image_files: - self.update_status_message("No images to delete.") + """Standard entry point for batch deletion. + Deletes all images currently in batches. + """ + if not self.batches: + self.update_status_message("No images in batch to delete.") return - # Collect all indices in batches + # 1. Collect all indices from batches (filter to valid range) + max_index = len(self.image_files) - 1 indices_to_delete = set() for start, end in self.batches: for i in range(start, end + 1): - if 0 <= i < len(self.image_files): + if 0 <= i <= max_index: 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 - min_deleted_index = min(sorted_indices) - - # Try to ensure recycle bin is available - recycle_bin_available = self._ensure_recycle_bin_dir() + # 2. Call unified engine + summary = self._delete_indices(list(indices_to_delete), "batch") - deleted_count = 0 - permanent_delete_mode = not recycle_bin_available - 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) - ] - - 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 - 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 + # 3. Clear batches only if all intended images were deleted + if summary["all_deleted"]: 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 - 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() - - 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) + log.info("Batch state cleared after successful deletion.") + elif summary["cancelled"]: + log.info("Batches retained after user cancelled deletion.") else: - self.update_status_message("No images were deleted.") + log.info("Batches retained after failed/empty deletion.") def _restore_backup_safe(self, saved_path_str: str, backup_path_str: str) -> bool: """ @@ -3427,6 +3446,7 @@ def _shutdown_executors(self): log.info("Shutting down background executors...") self._hist_executor.shutdown(wait=False, cancel_futures=True) self._preview_executor.shutdown(wait=False, cancel_futures=True) + self._save_executor.shutdown(wait=False, cancel_futures=True) def empty_recycle_bin(self): """Permanently deletes all files in all tracked recycle bins.""" @@ -5277,7 +5297,14 @@ 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}, ...] + List of dicts: [{ + "path": absolute_path, + "count": total_count, + "jpg_count": num_jpg, + "raw_count": num_raw, + "other_count": num_other, + "file_paths": [list of file names] + }, ...] """ stats = [] # Filter out bins that don't exist anymore @@ -5286,10 +5313,33 @@ def get_recycle_bin_stats(self) -> List[Dict[str, Any]]: 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}) + jpg_count = 0 + raw_count = 0 + other_count = 0 + file_names = [] + + for p in bin_path.iterdir(): + if p.is_file(): + file_names.append(p.name) + ext = p.suffix.lower() + if ext in [".jpg", ".jpeg"]: + jpg_count += 1 + elif ext in RAW_EXTENSIONS: + raw_count += 1 + else: + other_count += 1 + + if file_names: + stats.append( + { + "path": str(bin_path), + "count": len(file_names), + "jpg_count": jpg_count, + "raw_count": raw_count, + "other_count": other_count, + "file_paths": sorted(file_names), + } + ) except OSError: continue diff --git a/faststack/final_all_fail.txt b/faststack/final_all_fail.txt new file mode 100644 index 0000000000000000000000000000000000000000..d4986b1aa9a50f61a35c3376e2cd2c2b895d38ed GIT binary patch literal 8860 zcmeI2T~8ZF6o$`rrTz!sRHRa4J`CnVO)Jt6HBu_kQm*25MUBBE#&g8_`O0UJ6-&>=I5_EwiwkkgwIh0rW{rJE^`qW2 zMY*c8Cy903e2nY3-n1dXbB(IHqvELMtu1j?k$md9UZ{6VRECZU9E5!zYPI1$6Xl-d z*jL}oNeaGbtvb&3#ADtUNrv`Jb6<(?f!=l1C)13VlI*#jGxxTsl{H=eOk7;)zNt3w z#?J3c;zxF1Z}jv*XGO^P#{O{Jh8Y*ZDL7$wGKUys`_~vRv#nx#A**PL$F7ra=ZAa%dr_yL|wLuk<#cSu1{9W|f+uz2&w6^T6u6Qcm=GOkT+vg+E zIn(>tgD?s@e*7$UpeDb+Jp5m3Z9Zbc52I^f8vJ;VUbX|9^2p1x0<@bQ$n<;dR^y|+ zyU5v0>f!&1oL~=w{JpKiMuNdXDfYLLdOFVvj>mfPMv@Cr(7wYmsA%dODdLIop3~2* zD)uVv|F)8LI;V|RP1oDUY=NVEdN zik`Mi%j?OwYM!uqnj zx2PwhuAqna#U*!r#)0FVT%lF!Dc*=LGO1#wK`w@m53%Mf8dJAaJhmv_1D3oD%Fwf& z)b{y#_o%@$frvxNGma;Vc5G7m;4O^Igf)CdMgD%@-r4&?*0!DW5sTy-te0`Ulk^LV z{g_Av5>{h;~A(%hKzw)WSL)>J}<4va8B*u^_U5KWgpNfjzgN z(tjHuo~)`L6=7^$JnDsg>!cE>&hq=G&2lWetZR|1?~_jvL+m@zdrOktmo&+h*xf6) zs|R}WS?miZ(StGeru78N-#a9niVB{$Vk;%ORMk8^?PBhSeN}Ho{m7R$$)ikN-2d(U<+_A$?PFOE~NcZ;)o#RzcrdptW| zwfV}Q$3eWkk~g7>M8(O+NmP>_8lz%K;(9@d+2XIdv&L_G4~kynT=f1}Z`nBwwt4n_ s_C5JG0Dj(o)21p&thb?fk$)cWj|gHCYJom3=6@7uIoaO4bNM{vUt|9vcmMzZ literal 0 HcmV?d00001 diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index 3b0e83c..b17c923 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -644,6 +644,9 @@ def _apply_edits( apply_rotation = abs(straighten_angle) > 0.001 and (for_export or has_crop_box) + # Capture original dimensions BEFORE rotation for crop coordinate transformation + orig_h, orig_w = arr.shape[:2] + if apply_rotation: # Use the float rotation helper # Note: rotate_autocrop_rgb logic was complex. @@ -696,17 +699,46 @@ def _apply_edits( if has_crop_box: crop_box = edits.get("crop_box", 0.0) if len(crop_box) == 4: - # 0-1000 relative to current size - h, w = arr.shape[:2] - left = int(crop_box[0] * w / 1000) - t = int(crop_box[1] * h / 1000) - r = int(crop_box[2] * w / 1000) - b = int(crop_box[3] * h / 1000) - - left = max(0, left) - t = max(0, t) - r = min(w, r) - b = min(h, b) + # The crop_box is in 0-1000 normalized coordinates relative to the + # ORIGINAL (un-rotated) image. After rotation with expand=True, + # the original image is centered within a larger canvas. + # We need to transform the coordinates from original image space + # to the expanded canvas space. + + if apply_rotation and abs(straighten_angle) > 0.001: + # The original image (orig_w x orig_h) is centered in the + # rotated expanded canvas (new_w x new_h = arr.shape). + new_h, new_w = arr.shape[:2] + + # Calculate the offset: the original image's center is at + # the new canvas's center, so the top-left of the original + # image is offset by (new_w - orig_w)/2 and (new_h - orig_h)/2 + offset_x = (new_w - orig_w) / 2.0 + offset_y = (new_h - orig_h) / 2.0 + + # Convert crop_box from 0-1000 to pixel coordinates in + # original image space, then offset to canvas space + left = int(crop_box[0] * orig_w / 1000 + offset_x) + t = int(crop_box[1] * orig_h / 1000 + offset_y) + r = int(crop_box[2] * orig_w / 1000 + offset_x) + b = int(crop_box[3] * orig_h / 1000 + offset_y) + + left = max(0, left) + t = max(0, t) + r = min(new_w, r) + b = min(new_h, b) + else: + # No rotation - use current dimensions directly + h, w = arr.shape[:2] + left = int(crop_box[0] * w / 1000) + t = int(crop_box[1] * h / 1000) + r = int(crop_box[2] * w / 1000) + b = int(crop_box[3] * h / 1000) + + left = max(0, left) + t = max(0, t) + r = min(w, r) + b = min(h, b) if r > left and b > t: arr = arr[t:b, left:r, :] @@ -870,6 +902,16 @@ def _apply_edits( cached_exp_gain = cached.get("exp_gain", 1.0) cache_hit = True + # Validate cached array shapes match current Y dimensions + # This prevents reusing preview-resolution blurs during export + y_shape = Y.shape + for cached_arr in (Y20_cached, Y3_cached, Y1_cached): + if cached_arr is not None and cached_arr.shape != y_shape: + # Shape mismatch - invalidate cache + Y20_cached = Y3_cached = Y1_cached = None + cache_hit = False + break + # Compute exposure scale factor for reusing cached blurs # blur(k*Y) = k*blur(Y) is exact only if Y scales linearly with exposure. # Since highlights/shadows recovery (step 7) is non-linear and sits between diff --git a/faststack/imaging/math_utils.py b/faststack/imaging/math_utils.py index 4b52a98..1cac080 100644 --- a/faststack/imaging/math_utils.py +++ b/faststack/imaging/math_utils.py @@ -166,6 +166,7 @@ def _highlight_recover_linear( amount: float, *, pivot: float = 0.7, + k: float = 8.0, chroma_rolloff: float = 0.15, headroom_ceiling: float = 1.0, ) -> np.ndarray: @@ -184,6 +185,7 @@ def _highlight_recover_linear( rgb_linear: Float32 RGB array (H, W, 3) in linear light, may have values > 1.0 amount: Recovery strength 0.0-1.0 (mapped from slider -100 to 0) pivot: Brightness threshold below which no recovery occurs + k: Compression factor (adaptive). Higher k = stronger shoulder. chroma_rolloff: Desaturation amount in extreme highlights (0-1) headroom_ceiling: Maximum output brightness (> 1.0 preserves headroom detail) @@ -202,11 +204,25 @@ def _highlight_recover_linear( # 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) + # Highlights recovery: we want to pull down highlights to reveal detail. + # Rational compression formula: y = x / (1 + kx). + # We apply this relative to the pivot. + # normalization: brightness is already linear. + x_norm = (brightness - pivot) / (headroom_ceiling - pivot + eps) + x_norm = np.clip(x_norm, 0.0, None) + + # Compressed value (normalized context) + # At amount=1, we use full rational compression. + # At amount=0, we use identity. + compressed_norm = x_norm / (1.0 + k * amount * x_norm) + + # Map back to brightness scale + target_brightness = pivot + compressed_norm * (headroom_ceiling - pivot) + # Clamp to headroom_ceiling to satisfy docstring contract (small amount can cause overshoot) + target_brightness = np.minimum(target_brightness, headroom_ceiling) + + # If brightness was below pivot, keep it as is + target_brightness = np.where(brightness > pivot, target_brightness, brightness) # Rescale RGB to preserve hue/chroma # Protect against div-by-zero or huge scale factors for near-black pixels diff --git a/faststack/imaging/metadata.py b/faststack/imaging/metadata.py index baf82ac..a1c06e8 100644 --- a/faststack/imaging/metadata.py +++ b/faststack/imaging/metadata.py @@ -58,9 +58,7 @@ def get_exif_data(path: Union[str, Path]) -> Dict[str, Any]: if not exif: return {"summary": {}, "full": {}} - except ( - Exception - ) as e: + except Exception as e: log.warning(f"Failed to extract EXIF from {path}: {e}") return {"summary": {}, "full": {}} diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index c06c857..5383ee8 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -828,15 +828,15 @@ def _decode_and_cache( # 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() diff --git a/faststack/io/deletion.py b/faststack/io/deletion.py index 9ccb599..64ac168 100644 --- a/faststack/io/deletion.py +++ b/faststack/io/deletion.py @@ -4,6 +4,7 @@ from pathlib import Path from PySide6.QtWidgets import QMessageBox + log = logging.getLogger(__name__) diff --git a/faststack/io/indexer.py b/faststack/io/indexer.py index 27737a8..af7dcd8 100644 --- a/faststack/io/indexer.py +++ b/faststack/io/indexer.py @@ -45,9 +45,9 @@ def find_images(directory: Path) -> List[ImageFile]: # 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() diff --git a/faststack/loupe_fail.txt b/faststack/loupe_fail.txt new file mode 100644 index 0000000000000000000000000000000000000000..c045057874e3dc1574343d28287eb40371339066 GIT binary patch literal 8158 zcmd6sYi|=r6o%(>rT&L)Dujc;4GEMG5hMr(A(aY2Q9t0ya&9J&#Fpc3jMe-ip(tg$owTNcB1 z_%?hKM&VenCgD)9$Kgcq{t%wiuo2even(dkUWYH5@nMR6BecU^jq+Rzw>3VB<3{bB zu&I%Iy4R;;$KgB-bbS%5yZZj4?zM<}EQmuv-WSGk*b<~2J-cB~U-!jxS5)=IBYeW~ zUicK(F@CDCBSGbPthua)j*%b@!$*DD)0(#Kp}0Dptvimh89NY;LVOm2z`qk=K_c8P z`!mtt?>(&?ikFe*_9A`YWwaeJ7y5m!ySSF?eU$cExDkGvQMtV4)5=0LKM60xPvKp7 z6FT9os86F`^M)LI*(=$0RgOLB>|FXAOQxriY)_gwk=_SU;(XnchWDZdd(k$~y}hB= z-KmZAq{(y5+tUjCrpQv!o5}G+dd0TDk7Z4w4Vk5kqdbc6C}WFl;T!2{OM?9FWLCFi z>!0NdcV+jxdTz$Iz9?G@tAdU16>-j*o_5*nr&_fsjob?l^t%=w&**gv!$%IaI$0=R z+$yh2nl0(1NnWe+MRe~}`tM5M`X%ZFo~!ySBpu>TG1DuSa4`LkE*Kr?ziS!3hx(5{ z61n;sH@hh+-dTj!RIkJeX5#s)(wb3}qxE~ieXDts=$_XeolS`h(t9A{5{e3qOkbfxfNk%0X_HbX)Kp7hYi4EGHD>ghhzWf^5#x-@$`im}CVa!0(7cg9h}4nNsu5!cuZw4LLv`ZYJBD$fJO6)bWr zyYEY0Wvll}0eS7qhOxpNL~=cLiI3#QT^1u@n8=H;;HG&#eS>a}XV)THq`PS-P8oGJlNqUP9( zmW0Rb>mT(1bwnMLbK<`VdR|VK!_te@w`B^?Di_BEr{Q~9^V?XbdCcKWCl8OgiJ=R<9O47_#WNmr(NSs(krrNXY ze6BY#fK`1~H#W=N03X+!2 zO;TslT3eRcL=C>&)Cb)U&(sTbWC_*>0U3WMz9BoK+NvC^%%h6;NaZM2kMSuAxNWkl z@k6wLTf$?u#O?{s6<65T@#T(gE zNW3ATWMBE-)+L&c7YROHn(eb;3D&Ln&Vys7?-+<@W>^p0no)xdR_!mRSEpt^!rT;G zcHEN1VLfd{j3ziuev9uB2j@9=Sr#*#@J_S=V^_bOT686k-H@~ygNG+EE?I>1v1Ur0 zbN{^&sjJh56w9(%)O|aYv1Xf}H2X(OQdkskEh0O_oU}bsHN{D)&}Iu{7n5BQ^l}-y z^KRQLt)3;8wY+j~Ml6G-9M5&l%+u{w1Z8*xJt$Ic$$NJ61#fNXa(|uo5WY-eOiG>M zB8``=;wrQxNhf-pqN?WI0xa@W@qHJyR+mY$zQI?{gZ0%Ov$0>IuGPxk)6{1ptB$;q zj%OsPSq7kLr)Go86baGiqWb;v9&?ekgFE#riCeIDG?u%Xau{)>&UK20`B>G23u%=r zMBsbo`MUaW5qEH0kBnuE|3&2p$95L;E^2+OXE%BqRl->Bw&VRw-{`%%IzRAgud5w0 z`pbcy{pkCANhe6ve;cY`*W>ylRhr$HAJ~)feyBT>rk%2EsSmiP8#y=W84ftTTIS*$ zik*GWzf>&v&3?>ZWKTSS(UIPl!N3|Qq^5nUlb08fpB+i;AWGEtx)+}-%&%TN7yEX| z%8- zh&^N>2<20wbh=#DJx}WO%lo)Uak=v`?1$4-YV3rYuhj44xpCY{ zzzU#{Otj1eg?_r%k*y_MSKDRSn#rfS_t&&DSKr;g%6Z*#lC@sg>lyS*HscgK*L!gK NLhPZozq)Lp@L%)D%e(*p literal 0 HcmV?d00001 diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 9031654..28160ee 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -609,6 +609,8 @@ Item { isCropDragging = true } + // Ensure loupeView has active focus so Escape key works + loupeView.forceActiveFocus() return } @@ -855,6 +857,8 @@ Item { cropDragMode = "none" // Settle zoom/pan after rotation ends (Force recompute) if (mainMouseArea.isRotating) imageRotator.recomputeFitScale(true) + // Ensure loupeView has active focus so Escape key works + loupeView.forceActiveFocus() } } diff --git a/faststack/qml/ImageEditorDialog.qml b/faststack/qml/ImageEditorDialog.qml index df7c73c..5b940da 100644 --- a/faststack/qml/ImageEditorDialog.qml +++ b/faststack/qml/ImageEditorDialog.qml @@ -7,7 +7,7 @@ import QtQuick.Window 2.15 Window { id: imageEditorDialog width: 800 - height: 750 + height: 820 title: uiState && uiState.editorFilename ? "Image Editor - " + uiState.editorFilename + " (" + uiState.editorBitDepth + "-bit)" : "Image Editor" visible: uiState ? uiState.isEditorOpen : false flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint @@ -67,9 +67,10 @@ Window { Shortcut { sequence: "S" context: Qt.WindowShortcut + enabled: uiState ? !uiState.isSaving : true onActivated: { controller.save_edited_image() - uiState.isEditorOpen = false + // Note: Editor closes automatically via _on_save_finished callback } } @@ -396,16 +397,17 @@ Window { // Save (Primary) Button { - text: "Save" + text: uiState && uiState.isSaving ? "Saving..." : "Save" Layout.preferredWidth: 100 highlighted: true + enabled: uiState ? !uiState.isSaving : true Material.background: imageEditorDialog.accentColor onClicked: { controller.save_edited_image() - uiState.isEditorOpen = false + // Note: Editor closes automatically via _on_save_finished callback } background: Rectangle { - color: parent.pressed ? Qt.darker(imageEditorDialog.accentColor, 1.1) : imageEditorDialog.accentColor + color: parent.enabled ? (parent.pressed ? Qt.darker(imageEditorDialog.accentColor, 1.1) : imageEditorDialog.accentColor) : Qt.darker(imageEditorDialog.accentColor, 1.5) radius: 4 // Subtle shadow simulation layer.enabled: true diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index e261cc0..b884134 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -24,7 +24,6 @@ ApplicationWindow { } if (uiState && uiState.hasRecycleBinItems) { close.accepted = false - recycleBinCleanupDialog.text = uiState.recycleBinStatsText recycleBinCleanupDialog.open() } else { close.accepted = true @@ -1248,29 +1247,130 @@ ApplicationWindow { color: "black" } } - MessageDialog { + Dialog { 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 + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + width: Math.min(550, parent.width * 0.85) + modal: true + standardButtons: Dialog.NoButton - detailedText: "Select 'Yes' to permanently delete these files and quit.\nSelect 'No' to quit but keep files in the recycle bins." + background: Rectangle { + color: root.isDarkTheme ? "#2d2d2d" : "#ffffff" + border.color: root.isDarkTheme ? "#555555" : "#cccccc" + radius: 8 + } - onButtonClicked: function(button, role) { - if (button === MessageDialog.Yes) { - uiState.cleanupRecycleBins() - allowCloseWithRecycleBins = true - Qt.quit() - } else if (button === MessageDialog.No) { - allowCloseWithRecycleBins = true - Qt.quit() + header: Rectangle { + implicitHeight: 50 + color: root.isDarkTheme ? "#333333" : "#f0f0f0" + radius: 8 + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 8 + color: parent.color + } + Text { + anchors.centerIn: parent + text: "Clean up Recycle Bins?" + color: root.currentTextColor + font.bold: true + font.pixelSize: 18 + } + } + + // Use Column inside the default content area + Column { + id: dialogContent + anchors.fill: parent + anchors.margins: 20 + spacing: 12 + + Text { + width: parent.width + text: uiState ? uiState.recycleBinStatsText : "Loading..." + color: root.currentTextColor + wrapMode: Text.WordWrap + font.pixelSize: 14 + lineHeight: 1.4 + } + + Text { + text: detailedSection.visible ? "▼ Hide File List" : "▶ Show File List" + color: "#4fb360" + font.pixelSize: 13 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: detailedSection.visible = !detailedSection.visible + } + } + + Rectangle { + id: detailedSection + width: parent.width + height: visible ? 180 : 0 + visible: false + color: root.isDarkTheme ? "#1a1a1a" : "#f5f5f5" + border.color: root.isDarkTheme ? "#444444" : "#cccccc" + border.width: 1 + radius: 4 + clip: true + + Behavior on height { NumberAnimation { duration: 150 } } + + Flickable { + anchors.fill: parent + anchors.margins: 8 + contentWidth: detailsText.width + contentHeight: detailsText.height + clip: true + + Text { + id: detailsText + text: uiState ? uiState.recycleBinDetailedText : "" + color: root.currentTextColor + font.family: "Consolas" + font.pixelSize: 12 + } + } + } + + // Spacer + Item { width: 1; height: 10 } + + // Buttons row + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 15 + + Button { + text: "Cancel" + flat: true + onClicked: recycleBinCleanupDialog.close() + } + Button { + text: "Keep and Quit" + onClicked: { + allowCloseWithRecycleBins = true + recycleBinCleanupDialog.close() + Qt.quit() + } + } + Button { + text: "Delete and Quit" + highlighted: true + Material.accent: "#e57373" + onClicked: { + uiState.cleanupRecycleBins() + allowCloseWithRecycleBins = true + recycleBinCleanupDialog.close() + Qt.quit() + } + } } - // Cancel does nothing, just closes dialog } } } diff --git a/faststack/qml/ThumbnailGridView.qml b/faststack/qml/ThumbnailGridView.qml index 6829b7f..e696fb3 100644 --- a/faststack/qml/ThumbnailGridView.qml +++ b/faststack/qml/ThumbnailGridView.qml @@ -130,7 +130,7 @@ Item { // Empty state Text { anchors.centerIn: parent - visible: thumbnailGrid.count === 0 + visible: thumbnailGrid.count === 0 && uiState && uiState.isFolderLoaded text: "No images in this folder" color: gridViewRoot.isDarkTheme ? "#888888" : "#666666" font.pixelSize: 16 diff --git a/faststack/test_fail_out.txt b/faststack/test_fail_out.txt new file mode 100644 index 0000000000000000000000000000000000000000..383e3e0255608455fbe6a60fe7bb39645a3c2c5b GIT binary patch literal 7464 zcmeI1Z*LPv5XR?qCBDN637JZ8N}L1`DwQA!RV7|jP4lYPTIOsLgK@00la?<}`}}6T zxjQ(=e~3k1SgSkxZg*#&otb@RcJIH>FKpeC*wp$q(LK?fe-j(ng^m1qYF#T-H__9& zBfPMC_JfUWC|Of`C)tI4l)Qg5&dBzxt^bcz68p_Qsm1wh^gT;$SGC-C?2+onULVjt zwr8r@*VUfY4eip-R6a@8uHHYb@IG~%&xN?DZ(sf1dh1-)?5OWYJ)KGJL{AgR%vFM~VfB~N_K6+ak&ynW zYgcxCV1GH*DF60wp1ECnLV_oR*@77F=PBqltbjVUMA!ntw!Ck12**Of8tF>+_~Y~| ztavYWM$7naXJ)NZ>xsRVUVpVW6}|3jt!xf8E2(hXPIWJY932mZj8z#uwMnC;UJvB0 zHzP#erMwS|RqeOfBXdUuuAtG4b)WchrG4!Y z&+Op4%mOYMIR^~2qUyMUH-wc2^Y2_Egtyj@LS@f##8dlS>*GWi%Gz)F2g1%$rHs2B z_vUY3@5_yQd)$w04m@VDwr_hr;EbLymbXmhE$8;o?z^AYBX_F$u}Z2oF{+}^dK6m3 zs`ovQfFA;X5&f{}oAkw>nG{%^Zm1-nPLd@L=%~!H5m^vw5$2kvzoQlpZ3I&ZSOx&q{^g6a*3%* z$l0Q_LUQO5Y^i$Ohb|1LkB=Qq%i7wyDwpjI8Pxwcnl}fK;>7Q~_ zOKkmH*IF*CStVC!#(_=a>aAzwi}Es< zV861iqm+6Ux2gG5J-8Z8c)#VsDuZshFvOiL7v44(F3P_G|8WwuPT|}ICNKA%M1Ia? z*e}M@Eo#T&^L0j`4}5W?sP@WE?H6aWL+4(i0e-_-Ovs)NH7@&1qG33ZDH?juZ_8N@ ztd3-M(u~~9$<1%qix~BNS+nGMZ$TJYebv}-pscUV`J$9nVnY=7xfV0M3D|GbGDc+2~+O+9L?#{27?ND=?|UcvX9G8*vqQoAiqmkaH9 z!akd?GV7j!F}wN+UgYn36e#yCtAATquTpW*vOc_O|n&P6+!`QJP?vlsimxT|rM yyy?rmeE9MN#?`$v5%;o}4=31UIh^0@>mpY3B{F=$3K==y^xEpdzI^quK=?mmN&AZc literal 0 HcmV?d00001 diff --git a/faststack/test_final_fail.txt b/faststack/test_final_fail.txt new file mode 100644 index 0000000000000000000000000000000000000000..da786409346540215b02777e5c6d00b406aa8272 GIT binary patch literal 1050 zcmcJOTT8=05QWdP;D6W`-)!hbR0@3%^vC$9sbP2Qc51ZHN~OQInyE(DaN9M)MElxPr2?#0cVO3Ag7-q4QX>sH z_gGF>T4QnFMIR~B0IltP9ip$iKCtcS6wMJb>~w{ml(W`g4R}9B`k&b68Y@-`_M9wZ zc+AMkGP4|0J@OXm8C}U9Ysy;VylN0v*vzr5iQ)8nB=a*SB`g*& zw!Hw)ye-GdqdxGGf*c_$DpvS-=2v)Y(^&;`yduAxN8M^OSUH^_3v4qm8joS|H(hVY zb#qR9ZH$Q_8^45_mYj`Y>iZYoBhPaT-$~L=6*R5^Uov+>pawoNr literal 0 HcmV?d00001 diff --git a/faststack/test_loupe_out.txt b/faststack/test_loupe_out.txt new file mode 100644 index 0000000000000000000000000000000000000000..44a6443aa2fec6f4d68c5dd58838b9ae910177ba GIT binary patch literal 8646 zcmd6tZEw^@5Xbj*rG5uD2<3vnO~OL~HI-5b2nni)6!k@~vclzoz~yqfOIlFXuip0m z+qucwKA-P$(1I*GKJR*-cIH1b>-_WgR#^7P!g)9hV_h>{xf_R*a25vf_j%Y4BaMyq zYgrCk;p^~K7>2%RorfdQ9)(lU`$K#N;da>6{Vg3?*bN^v-ZpA8+v|E=lUh>N8w@UhMs62NXxF|*q2slhK{}PPS5i2 zyT%5(cEg^YjU?eP&go8gn{2sez1N9eBT+oloCD1riANUZbK*TD8Ayr~NzAn`dc2K} z!$|e5p7caH*Lfz3^&>yt>&MxQVZmd)mB%BCRMY68q{?LBO!HXfQ)vrtPG9x0boKi} zbkAhPq2?CRPDsUQ9_4x{3DaEc=;MVrUr6eao((jo5XV#5^;o})__QazdU1XsDK2#1 z(-_{Cg~wqh{1{$^rxW`@1z&OPw#9uYyJE5PXdBaU6g8>7J$o+JamrW*E9{1Ct@NJw zKmP1i!6Wg5BKjs8zMgpImt?Ugve>im{IgiBCwmN{-}JvIufda+N5}ML7Tmh8!b@3r zPgO61n_y7Fu+a z58f+ZY$$hZhP(Q86dvg0ZcTK-oGi{+*VPW2eMfUPHTQ1#PVcUVAEKr+McwGf}0Gv9WX0Z1T>rR@gk{S(Jq= z^dw^Iq4@8{x8LY)B~pPN^}<_8f2?O~I+`fAduA^BDdTxYx4J0{zs6DI`fVxN)7f

eMHkvo?G)1fOxL;x*m}ArHc^~skNG@^WtXW_(ZK!G|5B3@ zVTH~k9ab4_X)Vp)PGTitb?c6Fp(-226?Xc`H?#PT&0w{gbgMpXno+eU^&lQOlHczu zKFVJ2)im}xkPlNcHBqDZ?|*YF({oQ*{4V)^M4s zQZ!p-7RLMf*KJ&_pz|14i`4O*TCB9KaGj^GD}G1KPo&T#P|;Z}f1qFFd%A);C)dRP zChE<0T8v8(J5VC7bBWILXjTP8(5u$8Ar(vu8T{_hlZRf>Ox zzak%UDDlj`hQ$uP>j<$dU*pOS5BbV+bDi$4_*JXcChy8(Rhg>1j%>0fYZwPA6R$h! zapBfKk^~?eI~!nK7JiCZd1jk5^ZJ|(?S|kF z-seD>cn9CGok?v~3|{7_;yq9q#p)QJP{4f?ANw|Xzzy*+U*p*g9c6EsmFKK;DD$Kn zc_UhMHrydM)&;wgN3jFRGpdk%MFX>3y7b<7;*%$eq7%&l(@n~Yh<@xyjb-t&9m@A^ zo0obE{0A?g!0K=7o+sqOdhJStT0d#>yJhTMqg15YormC3VgH@AW8MT{g=g34LdE*G^?5`&*o} z$45t2n1#27$WF1z+KyChI7t=S?t$#5v-{4vEaG?GP1|ltJxeU>dF5UVEJLOyo$D(z zEw_6Sk`WQ~m{_@^=y{|kRO+492w}C)BPOLzaMp@XM@-p!M(j1T3>dLzZ81hv4 zeG^x$Zj<))hF>`k*57u_#(zOwtChW{sn5n%JBms=9=vCQ#PRE}tD z-wa>HRUhfvjnPJxFw(v4Xg`iIxv$^Ph3)41UG0F;UmokaA7h^<=__jWm(};Yf1*mW z8}kEuPTmi7FRkg{`yRwz06Vv>GaB#(ciDq)rtIE(4yJ0Mq6J$`&%W-KrOtau_=|LS zsoM3K&ZnYhyW7O5#VNG`6*pfG9!~zc9X7S^&zXrrb*J1h-6}(?IKV*ayz;&>HuSna z^|R%@NMu~@8xPrsFVobKi8oKFQNi+2?3TeKBn0`FIgrr5Q1Qhy@w!?k{b{};yXEJ9 hFI~>E*6%qN))&O)zZIJ5YrY_?t3#xIy}E6Y@L!9(J1zhK literal 0 HcmV?d00001 diff --git a/faststack/test_results_debug.txt b/faststack/test_results_debug.txt new file mode 100644 index 0000000000000000000000000000000000000000..79718cabbb2e7587401c3b866474ca2554764737 GIT binary patch literal 35358 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

ThZq+vLS|i7Hx1hEGecjf48JD zovUQlA4UCG&z5yerc7RstV~OpOuvqDwQM$o-1AKAie=SSyC7@x8B((g8^cC~} zx^c#Jkyw$cfX#WOlrMEuEfPMHmG~fhtRC6k)rjVs8L?s;j-oUT3%Ph(@@L~1>OGBz z<}XcH*E;-J?&B57{ktRdUrL*r2=hR?jjr3GNtDYZ6Akh9iTKQn%+JE>V*ZLPaZOAk z{dc0_kzgWTd2n&cYnQV1fqKKU9@T|7qwPE8F8ZT;Wf6NK(PsQ_-tF1J(z zYexLo{j8s;=390Jt)g`#%Ubk?`RsN{)-i26z9w$n-o{$%f~3te7rekY`j$A~lvjuQ zhQ50INtj*F#xt%)^l_e@VeN@hPBl;D&tweUq=sYjCv&^h+xOz8=NV~Ro5kIg5Tq9_ zar&&xYL~}iWwZUHwdktuPLvL}xQ9~%J#k)jBV^mZw|gk!YI81lxpf8AOdBwy-@D_{)l6 zcfF0(SHZ$rrC;n874^%kdP_3C60PDmeyEn`X}6Qp+@!ivj>G4U988X8T+bN~jj$|) z_OoV)n_3mBsr4D+n)th`wwr1_-wbhjZr)b*n2+W>AH>3#H={a$Omok^1#-*YpzmvV zG>g{ccSQ2B1u-rAOtw2oOLSsct7rKvQjpa|t{>>!SG#YWc~(p2wE3a!I~M0yM|*PA zWEzpr2`6!JZ=_q(XD7b?Wfbc$*JnWayrXOk=M&!z;62+qk962RDjA8%8~)M1El904 zwS{!fE}Kd0V|LvmOdrSp^O;r|GNdx4O?Ga!p7<}H$#~@jejUoA;U~)|^34L49~~)s z@7OGEB~Ptx=j|7w)=sjPd2M7LqNu_7=z1YW=~#pE9PyR(pKP&e24-vVf<+mku7Xu~ zSlRz}IWRusp8S7)*4eKNveln?albuZKBcY}>R?{>j@zOu9ezTieq=HZ=%PhM) zGiT0R_Um)3eV0?CxoURDZl_uctyK7Xqp2!%HMe~ujCHIdmCC_t^aysPC3x?|DKt=@ zbBE=0t2Gu+ZS;X6_0Zbh)hYVY>jT@4F3_AI{Z^OjRT*myR*&~{r2B(?rIBK#V9&@h zhR2kwEHle7)eCQt-q027u_nA#Ff6@I+{O;vBCI|T%c}x$h0P4xiWpA+M>0QSQov#X zW7~7^%-eD-J?b+rDaaABqGGv^XMVZ2Hl0;4!z=R3dDN|T4l75E9Cp^_CD~eRlTFua za^0L0UmIg$$i^?BrUhqXnEL*O_sH`c!T2xVIerh`I;EQlzDsTyxHY#L%#;;o&ANU` zwI{mKHB9f2zJ@x{{egI9zOOTw*d&B(jj>I?-9lZ%5BSZ^!7VTxGFgXXSi-{o$duaK YJT^OAZF;TGR%M&rr0v$W>dsg18*7cViU0rr literal 0 HcmV?d00001 diff --git a/faststack/tests/test_deletion_unification.py b/faststack/tests/test_deletion_unification.py new file mode 100644 index 0000000..d993331 --- /dev/null +++ b/faststack/tests/test_deletion_unification.py @@ -0,0 +1,329 @@ +import pytest +from unittest.mock import Mock, patch +from pathlib import Path +from faststack.app import AppController +from faststack.models import ImageFile + + +@pytest.fixture(scope="session") +def qapp(): + """Ensure a QApplication exists for tests that might touch UI elements.""" + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +@pytest.fixture +def mock_controller(tmp_path, qapp): + """Creates an AppController with mocked dependencies.""" + engine = Mock() + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.ImageEditor"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ThumbnailCache"), + patch("faststack.app.ThumbnailPrefetcher"), + patch("faststack.app.ThumbnailModel"), + patch("faststack.app.ThumbnailProvider"), + patch("faststack.app.UIState"), + patch("faststack.app.QCoreApplication"), + patch("faststack.app.Keybinder"), + ): + controller = AppController(tmp_path, engine) + + # Mock signals and methods for verification + controller.dataChanged = Mock() + controller.sync_ui_state = Mock() + controller.update_status_message = Mock() + controller.refresh_image_list = Mock() + controller.image_cache = Mock() + controller.prefetcher = Mock() + controller._thumbnail_model = Mock() + controller._thumbnail_model.rowCount.return_value = 0 + + return controller + + +def test_delete_batch_images_success(mock_controller): + """Test deleting a batch of images to recycle bin.""" + # Setup state + img1 = ImageFile(Path("test1.jpg")) + img2 = ImageFile(Path("test2.jpg")) + img3 = ImageFile(Path("test3.jpg")) + mock_controller.image_files = [img1, img2, img3] + mock_controller.batches = [[0, 1]] # Delete test1 and test2 + mock_controller.undo_history = [] + + # Mock _move_to_recycle + mock_controller._move_to_recycle = Mock( + side_effect=lambda p: Path("recycle") / p.name + ) + + with patch("faststack.app.log.info") as mock_log: + mock_controller.delete_batch_images() + + # Verify standardized action used + found_log = any( + "type='batch'" in call.args[0] + for call in mock_log.call_args_list + if "Deletion complete" in call.args[0] + ) + assert found_log + + # Verifications + assert mock_controller._move_to_recycle.call_count == 2 + # Note: refresh_image_list is now deferred via QTimer.singleShot for faster UI + # We verify sync_ui_state was called (immediate UI update) instead + mock_controller.sync_ui_state.assert_called_once() + assert mock_controller.batches == [] + mock_controller.update_status_message.assert_called_with("Deleted 2 images") + + +def test_grid_delete_selection(mock_controller): + """Test deleting images selected in grid view.""" + # Setup state + img1 = ImageFile(Path("test1.jpg")) + img2 = ImageFile(Path("test2.jpg")) + mock_controller.image_files = [img1, img2] + mock_controller._path_to_index = {img1.path.resolve(): 0, img2.path.resolve(): 1} + + # Mock selection in thumbnail model + mock_controller._thumbnail_model.get_selected_paths.return_value = [img1.path] + mock_controller._move_to_recycle = Mock(return_value=Path("recycle/test1.jpg")) + + with patch("faststack.app.log.info") as mock_log: + mock_controller.grid_delete_at_cursor(0) + found_log = any( + "type='grid_selection'" in call.args[0] + for call in mock_log.call_args_list + if "Deletion complete" in call.args[0] + ) + assert found_log + + mock_controller._thumbnail_model.clear_selection.assert_called_once() + mock_controller.update_status_message.assert_called_with( + "Image moved to recycle bin" + ) + + +def test_grid_cursor_correct_mapping(mock_controller): + """CRITICAL: Test that grid delete at cursor uses path mapping, NOT raw index.""" + # Setup: Application order is 0:A, 1:B + # Grid order is 0:B, 1:A (reversed sort) + imgA = ImageFile(Path("A.jpg")) + imgB = ImageFile(Path("B.jpg")) + mock_controller.image_files = [imgA, imgB] + mock_controller._path_to_index = {imgA.path.resolve(): 0, imgB.path.resolve(): 1} + + # User clicks 'Delete' on Grid Index 0 (which is image B) + mock_controller._thumbnail_model.get_selected_paths.return_value = [] + # Mock entry at index 0 returns path B + mock_entry = Mock() + mock_entry.path = imgB.path + mock_entry.is_folder = False + mock_controller._thumbnail_model.get_entry.return_value = mock_entry + + mock_controller._move_to_recycle = Mock(return_value=Path("recycle/B.jpg")) + + # Call delete at grid index 0 + mock_controller.grid_delete_at_cursor(0) + + # VERIFY: Image B (app index 1) was sent to deletion engine + # We check _move_to_recycle was called with B's path + mock_controller._move_to_recycle.assert_called_once_with(imgB.path) + + +def test_partial_recycle_feedback(mock_controller): + """Test behavior when JPG recycles but RAW fails and undo also fails. + + With atomic pair behavior, if RAW exists and fails to move, we try to undo + the JPG move. If undo also fails (common in tests), the image is marked as + deleted to prevent UI resurrection of a missing file. + """ + img = ImageFile(Path("test.jpg")) + img.raw_pair = Path("test.DNG") + mock_controller.image_files = [img] + + # Mock RAW exists but fails to recycle + with patch("faststack.models.Path.exists", return_value=True): + mock_controller._move_to_recycle = Mock( + side_effect=[Path("recycle/test.jpg"), None] + ) + + mock_controller.delete_current_image() + + # Undo failed (paths don't exist in test), so: + # - Image is marked as deleted (jpg_moved=True) + # - No fallback dialog (can't act on it) + # - Image removed from list (not resurrected) + assert len(mock_controller.image_files) == 0 + # Warning message shown to user + mock_controller.update_status_message.assert_called() + + +def test_permanent_delete_fallback_cancelled(mock_controller): + """Test that batches are NOT cleared if user cancels permanent delete fallback.""" + img1 = ImageFile(Path("test1.jpg")) + mock_controller.image_files = [img1] + mock_controller.batches = [[0, 0]] + + mock_controller._move_to_recycle = Mock(return_value=None) + + with patch("faststack.app.confirm_permanent_delete", return_value=False): + mock_controller.delete_batch_images() + + assert mock_controller.batches == [[0, 0]] + mock_controller.update_status_message.assert_called_with("Deletion cancelled") + + +def test_delete_current_image_triggers_batch_dialog(mock_controller): + """Test that delete_current_image triggers the multi-image dialog if a batch exists.""" + img1 = ImageFile(Path("test1.jpg")) + mock_controller.image_files = [img1] + mock_controller.current_index = 0 + + # Mock a batch containing the current image + mock_controller.get_batch_count_for_current_image = Mock(return_value=5) + mock_controller.main_window = Mock() + + mock_controller.delete_current_image() + + # Verify dialog was opened instead of immediate deletion + mock_controller.main_window.show_delete_batch_dialog.assert_called_once_with(5) + # Ensure _delete_indices was NOT called (deletion is deferred to dialog) + mock_controller._delete_indices = Mock() + assert mock_controller._delete_indices.call_count == 0 + + +def test_grid_cursor_not_found_feedback(mock_controller): + """Test standardized feedback for grid cursor delete when image not found.""" + mock_controller._thumbnail_model.get_selected_paths.return_value = [] + mock_entry = Mock() + mock_entry.path = Path("missing.jpg") + mock_entry.is_folder = False + mock_controller._thumbnail_model.get_entry.return_value = mock_entry + + mock_controller._path_to_index = {} # Image not in list + + mock_controller.grid_delete_at_cursor(0) + + mock_controller.update_status_message.assert_called_with( + "Image not found in current list." + ) + + +def test_delete_indices_summary_return(mock_controller): + """Test that _delete_indices returns the expected summary dictionary.""" + img1 = ImageFile(Path("test1.jpg")) + mock_controller.image_files = [img1] + mock_controller._move_to_recycle = Mock(return_value=Path("recycle/test1.jpg")) + + result = mock_controller._delete_indices([0], "test") + + assert result["total_deleted"] == 1 + assert result["recycled"] == 1 + assert result["permanent"] == 0 + assert result["cancelled"] is False + + +def test_grid_cursor_mapping_regression(mock_controller): + """Locked-in regression test: Ensure grid delete at index 0 maps to correct app index. + + Setup: + - App internal list: [B, A] (A is at index 1) + - Grid view (sorted): [A, B] (A is at index 0) + + User presses Delete on Grid index 0. We must delete A (app index 1). + """ + imgA = ImageFile(Path("A.jpg")) + imgB = ImageFile(Path("B.jpg")) + mock_controller.image_files = [imgB, imgA] + mock_controller._path_to_index = {imgB.path.resolve(): 0, imgA.path.resolve(): 1} + + # User on Grid Index 0 (A.jpg) + mock_controller._thumbnail_model.get_selected_paths.return_value = [] + mock_entry = Mock() + mock_entry.path = imgA.path + mock_entry.is_folder = False + mock_controller._thumbnail_model.get_entry.return_value = mock_entry + + mock_controller._move_to_recycle = Mock(return_value=Path("recycle/A.jpg")) + + # EXECUTE: Delete at grid index 0 + mock_controller.grid_delete_at_cursor(0) + + # VERIFY: Image A (application index 1) was deleted + mock_controller._move_to_recycle.assert_called_once_with(imgA.path) + + +def test_grid_delete_folder_feedback(mock_controller): + """Test feedback when attempting to delete a folder in grid.""" + mock_controller._thumbnail_model.get_selected_paths.return_value = [] + mock_entry = Mock() + mock_entry.is_folder = True + mock_controller._thumbnail_model.get_entry.return_value = mock_entry + + mock_controller.grid_delete_at_cursor(0) + + mock_controller.update_status_message.assert_called_with( + "Cannot delete folders in grid view." + ) + + +def test_delete_updates_path_resolver(mock_controller): + """Test that deletion schedules a path resolver update via deferred refresh. + + Note: The actual path resolver update happens in a deferred QTimer callback, + so we verify the _refresh_scheduled flag is set (scheduling happened). + """ + + img1 = ImageFile(Path("test1.jpg")) + mock_controller.image_files = [img1] + mock_controller._move_to_recycle = Mock(return_value=Path("recycle/test1.jpg")) + mock_controller._path_resolver = Mock() + mock_controller._refresh_scheduled = False # Initialize the flag + + # Configure shared mock for the model in both calls + mock_controller._thumbnail_model.rowCount.return_value = 1 + mock_controller._thumbnail_model.get_entry.return_value = Mock( + path=img1.path, is_folder=False + ) + + # 1. Selection path + mock_controller._thumbnail_model.get_selected_paths.return_value = [img1.path] + mock_controller.grid_delete_at_cursor(0) + + # Verify deferred refresh was scheduled (path resolver update happens there) + assert mock_controller._refresh_scheduled is True + + +def test_partial_delete_cancel_preserves_batch(mock_controller): + """Test that if some images in a batch fail to delete and user cancels, batch is NOT cleared.""" + img1 = ImageFile(Path("test1.jpg")) + img2 = ImageFile(Path("test2.jpg")) + mock_controller.image_files = [img1, img2] + mock_controller.batches = [[0, 1]] + + # img1 recycles successfully, img2 fails + def mock_recycle(p): + if p == img1.path: + return Path("recycle/test1.jpg") + raise PermissionError("Fail img2") + + mock_controller._move_to_recycle = Mock(side_effect=mock_recycle) + + # User cancels permanent delete for img2 + with patch("faststack.app.confirm_permanent_delete", return_value=False): + # We need to mock rowCount for the resolver update that happens during refresh + mock_controller._thumbnail_model.rowCount.return_value = 1 + mock_controller.delete_batch_images() + + # Verify: + # 1. batches were NOT cleared because all_deleted was False + assert len(mock_controller.batches) == 1 + assert mock_controller.batches == [[0, 1]] diff --git a/faststack/tests/test_highlight_recovery.py b/faststack/tests/test_highlight_recovery.py index 942aa8c..b124e07 100644 --- a/faststack/tests/test_highlight_recovery.py +++ b/faststack/tests/test_highlight_recovery.py @@ -159,16 +159,16 @@ 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") @@ -210,6 +210,56 @@ def test_benchmark(): # Informational only - no hard assertion for CI stability +def test_amount_zero_identity(): + """amount=0 should return input unchanged (identity transform).""" + arr = np.random.rand(20, 20, 3).astype(np.float32) * 1.5 # Include headroom + + recovered = _highlight_recover_linear(arr.copy(), amount=0.0, pivot=0.5) + + # Should be extremely close to identity + diff = np.abs(recovered - arr).max() + assert diff < 1e-6, f"amount=0 should be identity, but max diff = {diff}" + + print("test_amount_zero_identity passed") + + +def test_pivot_behavior(): + """Values <= pivot should remain unchanged regardless of amount.""" + np.random.seed(123) + low_values = np.random.rand(15, 15, 3).astype(np.float32) * 0.4 # All below 0.5 + + for amount in [0.0, 0.5, 1.0]: + recovered = _highlight_recover_linear( + low_values.copy(), amount=amount, pivot=0.5 + ) + diff = np.abs(recovered - low_values).max() + assert diff < 1e-5, ( + f"Values below pivot changed with amount={amount}: max_diff={diff}" + ) + + print("test_pivot_behavior passed") + + +def test_increasing_amount_increases_compression(): + """Higher amount should result in more compression of highlights.""" + # Create bright image with headroom + bright = np.ones((10, 10, 3), dtype=np.float32) * 1.5 + + recovered_low = _highlight_recover_linear(bright, amount=0.3, pivot=0.5) + recovered_high = _highlight_recover_linear(bright, amount=0.9, pivot=0.5) + + # Higher amount should compress more (result closer to 1.0) + avg_low = recovered_low.mean() + avg_high = recovered_high.mean() + + # avg_high should be lower (more compressed toward 1.0) than avg_low + assert avg_high <= avg_low, ( + f"Higher amount should compress more: avg_low={avg_low:.4f}, avg_high={avg_high:.4f}" + ) + + print("test_increasing_amount_increases_compression passed") + + if __name__ == "__main__": try: test_monotonicity() @@ -220,6 +270,9 @@ def test_benchmark(): test_headroom_shoulder() test_analyze_highlight_state() test_source_clipping_detection() + test_amount_zero_identity() + test_pivot_behavior() + test_increasing_amount_increases_compression() test_benchmark() print("\nALL TESTS PASSED") except Exception as e: diff --git a/faststack/tests/test_loupe_delete.py b/faststack/tests/test_loupe_delete.py new file mode 100644 index 0000000..9df9574 --- /dev/null +++ b/faststack/tests/test_loupe_delete.py @@ -0,0 +1,144 @@ +import pytest +from unittest.mock import Mock, patch +from pathlib import Path +from faststack.app import AppController +from faststack.models import ImageFile + + +@pytest.fixture(scope="session") +def qapp(): + """Ensure a QApplication exists for tests that might touch UI elements.""" + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app is None: + app = QApplication([]) + return app + + +@pytest.fixture +def mock_controller(tmp_path, qapp): + """Creates an AppController with mocked dependencies.""" + # Mock dependencies + engine = Mock() + + # Mock internal components heavily to avoid initializing the full app + with ( + patch("faststack.app.Watcher"), + patch("faststack.app.SidecarManager"), + patch("faststack.app.ImageEditor"), + patch("faststack.app.ByteLRUCache"), + patch("faststack.app.Prefetcher"), + patch("faststack.app.ThumbnailCache"), + patch("faststack.app.ThumbnailPrefetcher"), + patch("faststack.app.ThumbnailModel"), + patch("faststack.app.ThumbnailProvider"), + patch("faststack.app.UIState"), + patch("faststack.app.QCoreApplication"), + patch("faststack.app.Keybinder"), + ): + controller = AppController(tmp_path, engine) + + # Manually mock signals that might be emitted + controller.dataChanged = Mock() + controller.dataChanged.emit = Mock() + controller.sync_ui_state = Mock() + controller._do_prefetch = Mock() + controller.update_status_message = Mock() + controller._thumbnail_model = Mock() + controller._thumbnail_model.rowCount.return_value = 0 + + return controller + + +def test_delete_current_image_recycle_success(mock_controller): + """Test successful deletion to recycle bin.""" + # Setup state + img1 = ImageFile(Path("test1.jpg")) + img2 = ImageFile(Path("test2.jpg")) + mock_controller.image_files = [img1, img2] + mock_controller.current_index = 0 + mock_controller.undo_history = [] + mock_controller.refresh_image_list = Mock() + mock_controller.image_cache = Mock() + mock_controller.prefetcher = Mock() + + # Mock _move_to_recycle to return a path (success) + mock_controller._move_to_recycle = Mock(return_value=Path("recycle/test1.jpg")) + + # Call delete + mock_controller.delete_current_image() + + # Verification + mock_controller._move_to_recycle.assert_called_with(img1.path) + # Note: refresh_image_list is now deferred via QTimer for faster UI + mock_controller.sync_ui_state.assert_called_once() + + # Verify undo history + assert len(mock_controller.undo_history) == 1 + action, record, ts = mock_controller.undo_history[0] + assert action == "delete" + assert record[0][0] == img1.path + assert record[0][1] == Path("recycle/test1.jpg") + + mock_controller.update_status_message.assert_called_with( + "Image moved to recycle bin" + ) + + # Verify cache/prefetch cleanup + mock_controller.image_cache.clear.assert_called_once() + mock_controller.prefetcher.cancel_all.assert_called_once() + + +def test_delete_current_image_recycle_fail_fallback_success(mock_controller): + """Test recycle bin failure falling back to permanent delete (confirmed).""" + # Setup state + img1 = ImageFile(Path("test1.jpg")) + mock_controller.image_files = [img1] + mock_controller.current_index = 0 + + # Mock _move_to_recycle to fail + mock_controller._move_to_recycle = Mock( + side_effect=PermissionError("Mock perm error") + ) + + # Mock external deletion module + with ( + patch( + "faststack.app.confirm_permanent_delete", return_value=True + ) as mock_confirm, + patch( + "faststack.app.permanently_delete_image_files", return_value=True + ) as mock_perm_delete, + ): + mock_controller.delete_current_image() + + mock_confirm.assert_called_once() + mock_perm_delete.assert_called_once_with(img1) + + mock_controller.update_status_message.assert_called_with( + "Permanently deleted 1 image(s)" + ) + + +def test_delete_current_image_cancel(mock_controller): + """Test user canceling permanent delete fallback.""" + # Setup state + img1 = ImageFile(Path("test1.jpg")) + mock_controller.image_files = [img1] + mock_controller.current_index = 0 + + # Mock _move_to_recycle to fail + mock_controller._move_to_recycle = Mock( + side_effect=PermissionError("Mock perm error") + ) + + # Mock external deletion module - user says NO + with patch( + "faststack.app.confirm_permanent_delete", return_value=False + ) as mock_confirm: + mock_controller.delete_current_image() + + mock_confirm.assert_called_once() + # verify no refresh or cache clear occurred + mock_controller.update_status_message.assert_called_with("Deletion cancelled") diff --git a/faststack/tests/test_reactive_delete.py b/faststack/tests/test_reactive_delete.py index d3e4f4a..7157d25 100644 --- a/faststack/tests/test_reactive_delete.py +++ b/faststack/tests/test_reactive_delete.py @@ -1,5 +1,6 @@ import pytest from unittest.mock import MagicMock, patch +from faststack.models import ImageFile @pytest.fixture @@ -27,8 +28,19 @@ def app_controller(tmp_path): patch("faststack.app.ThumbnailModel"), patch("faststack.app.ThumbnailPrefetcher"), patch("faststack.app.ThumbnailCache"), + patch("faststack.app.Keybinder"), + patch("faststack.app.UIState"), ): controller = AppController(image_dir, mock_engine, debug_cache=False) + # Mock depth + controller.refresh_image_list = MagicMock() + controller.update_status_message = MagicMock() + controller.sync_ui_state = MagicMock() + controller.image_cache = MagicMock() + controller.prefetcher = MagicMock() + controller._thumbnail_model = MagicMock() + controller._thumbnail_model.rowCount.return_value = 0 + return controller @@ -38,59 +50,65 @@ def test_reactive_delete_fallback(app_controller, tmp_path): 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 - + img_file = ImageFile(img_path) app_controller.image_files = [img_file] + app_controller.current_index = 0 # 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 + # Mock confirmation dialogs in app (where they are patched by tests normally) + with patch( + "faststack.app.confirm_permanent_delete", return_value=True ) as mock_confirm: # Mock permanent delete execution - with patch.object( - app_controller, "_permanently_delete_image_files", return_value=True + with patch( + "faststack.app.permanently_delete_image_files", return_value=True ) as mock_perm_delete: - app_controller._delete_grid_selected_images([img_path]) + app_controller.delete_current_image() # 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) + # Verify standard Refreshes/Cleanup + # With optimistic deletion, cache is cleared immediately before file I/O + app_controller.image_cache.clear.assert_called_once() + app_controller.prefetcher.cancel_all.assert_called_once() + # Note: refresh_image_list is now deferred via QTimer + app_controller.sync_ui_state.assert_called_once() + def test_reactive_delete_fallback_cancelled(app_controller, tmp_path): - """Test that user can cancel the fallback permanent delete.""" + """Test that user can cancel the fallback permanent delete and UI rolls back.""" 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 - + img_file = ImageFile(img_path) app_controller.image_files = [img_file] + app_controller.current_index = 0 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 + with patch( + "faststack.app.confirm_permanent_delete", return_value=False ) as mock_confirm: - with patch.object( - app_controller, "_permanently_delete_image_files" + with patch( + "faststack.app.permanently_delete_image_files" ) as mock_perm_delete: - app_controller._delete_grid_selected_images([img_path]) + app_controller.delete_current_image() mock_confirm.assert_called_once() mock_perm_delete.assert_not_called() + + # With rollback on cancelled deletion: + # 1. sync_ui_state called for optimistic UI update + # 2. sync_ui_state called again after rollback restores the list + assert app_controller.sync_ui_state.call_count == 2 + + # Verify the image was restored (rollback worked) + assert len(app_controller.image_files) == 1 + assert app_controller.image_files[0] == img_file diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index d0fc8b5..258819d 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -142,6 +142,7 @@ class UIState(QObject): # Recycle Bin Signals recycleBinStatsTextChanged = Signal() + recycleBinDetailedTextChanged = Signal() hasRecycleBinItemsChanged = Signal() isZoomedChanged = Signal() @@ -210,6 +211,7 @@ class UIState(QObject): 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 + isSavingChanged = Signal(bool) # Signal for save operation in progress def __init__(self, app_controller): super().__init__() @@ -260,6 +262,7 @@ def __init__(self, app_controller): self._cache_stats = "" self._is_decoding = False self._is_dialog_open = False + self._is_saving = False # Save operation in progress # Connect to controller's dialog state signal self.app_controller.dialogStateChanged.connect(self._on_dialog_state_changed) @@ -785,6 +788,16 @@ def isDialogOpen(self, new_value: bool): self._is_dialog_open = new_value self.isDialogOpenChanged.emit(new_value) + @Property(bool, notify=isSavingChanged) + def isSaving(self) -> bool: + return self._is_saving + + @isSaving.setter + def isSaving(self, new_value: bool): + if self._is_saving != new_value: + self._is_saving = new_value + self.isSavingChanged.emit(new_value) + @Property(bool, notify=anySliderPressedChanged) def anySliderPressed(self): return self._any_slider_pressed @@ -1199,6 +1212,15 @@ def debugMode(self, value: bool): 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 + isFolderLoadedChanged = Signal() # Emitted after first model refresh + + @Property(bool, notify=isFolderLoadedChanged) + def isFolderLoaded(self) -> bool: + """Returns True after the folder has been scanned at least once. + + Used by QML to avoid showing 'No images' message during initial load. + """ + return getattr(self.app_controller, "_folder_loaded", False) @Property(bool, notify=isGridViewActiveChanged) def isGridViewActive(self) -> bool: @@ -1348,18 +1370,43 @@ def gridPrefetchRange(self, startIndex: int, endIndex: int): @Property(str, notify=recycleBinStatsTextChanged) def recycleBinStatsText(self): - """Returns a formatted string of recycle bin stats.""" + """Returns a formatted string of recycle bin stats summary.""" stats = self.app_controller.get_recycle_bin_stats() if not stats: return "" - summary = "The following recycle bins contain items:\n\n" + summary = "The following recycle bins contain items:\n" for item in stats: - summary += f"• {item['path']}: {item['count']} files\n" - - summary += "\nDo you want to delete them before quitting?" + counts = [] + if item.get("jpg_count", 0) > 0: + counts.append(f"{item['jpg_count']} JPG") + if item.get("raw_count", 0) > 0: + counts.append(f"{item['raw_count']} RAW") + if item.get("other_count", 0) > 0: + counts.append(f"{item['other_count']} other") + + count_str = f" ({', '.join(counts)})" if counts else "" + summary += f"\n• {item['path']}:\n {item['count']} files{count_str}\n" + + summary += "\nDo you want to permanently delete them before quitting?" return summary + @Property(str, notify=recycleBinDetailedTextChanged) + def recycleBinDetailedText(self): + """Returns a detailed list of all file paths in recycle bins.""" + stats = self.app_controller.get_recycle_bin_stats() + if not stats: + return "" + + lines = [] + for item in stats: + lines.append(f"Directory: {item['path']}") + for fname in item.get("file_paths", []): + lines.append(f" - {fname}") + lines.append("") + + return "\n".join(lines) + @Property(bool, notify=hasRecycleBinItemsChanged) def hasRecycleBinItems(self): """Returns True if there are items in any recycle bin.""" @@ -1372,4 +1419,5 @@ def cleanupRecycleBins(self): self.app_controller.cleanup_recycle_bins() self.recycleBinStatsTextChanged.emit() + self.recycleBinDetailedTextChanged.emit() self.hasRecycleBinItemsChanged.emit() diff --git a/pyproject.toml b/pyproject.toml index 39afe2c..02a3a6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.5.3" +version = "1.5.4" authors = [ { name="Alan Rockefeller"}, ] From 3d7ca30f563c52650f11dccb4fd7f0ca5648ce1d Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Wed, 4 Feb 2026 23:37:04 -0500 Subject: [PATCH 2/5] Ignore local test output artifacts --- .gitignore | 5 +++++ faststack/final_all_fail.txt | Bin 8860 -> 0 bytes faststack/loupe_fail.txt | Bin 8158 -> 0 bytes 3 files changed, 5 insertions(+) delete mode 100644 faststack/final_all_fail.txt delete mode 100644 faststack/loupe_fail.txt diff --git a/.gitignore b/.gitignore index edbff18..7ebe831 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,8 @@ faststack/test_results.txt faststack/smoke_test_output.txt faststack/verify_result.txt + +# Local test/output artifacts +test* +faststack/*fail*.txt +faststack/*final*.txt diff --git a/faststack/final_all_fail.txt b/faststack/final_all_fail.txt deleted file mode 100644 index d4986b1aa9a50f61a35c3376e2cd2c2b895d38ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8860 zcmeI2T~8ZF6o$`rrTz!sRHRa4J`CnVO)Jt6HBu_kQm*25MUBBE#&g8_`O0UJ6-&>=I5_EwiwkkgwIh0rW{rJE^`qW2 zMY*c8Cy903e2nY3-n1dXbB(IHqvELMtu1j?k$md9UZ{6VRECZU9E5!zYPI1$6Xl-d z*jL}oNeaGbtvb&3#ADtUNrv`Jb6<(?f!=l1C)13VlI*#jGxxTsl{H=eOk7;)zNt3w z#?J3c;zxF1Z}jv*XGO^P#{O{Jh8Y*ZDL7$wGKUys`_~vRv#nx#A**PL$F7ra=ZAa%dr_yL|wLuk<#cSu1{9W|f+uz2&w6^T6u6Qcm=GOkT+vg+E zIn(>tgD?s@e*7$UpeDb+Jp5m3Z9Zbc52I^f8vJ;VUbX|9^2p1x0<@bQ$n<;dR^y|+ zyU5v0>f!&1oL~=w{JpKiMuNdXDfYLLdOFVvj>mfPMv@Cr(7wYmsA%dODdLIop3~2* zD)uVv|F)8LI;V|RP1oDUY=NVEdN zik`Mi%j?OwYM!uqnj zx2PwhuAqna#U*!r#)0FVT%lF!Dc*=LGO1#wK`w@m53%Mf8dJAaJhmv_1D3oD%Fwf& z)b{y#_o%@$frvxNGma;Vc5G7m;4O^Igf)CdMgD%@-r4&?*0!DW5sTy-te0`Ulk^LV z{g_Av5>{h;~A(%hKzw)WSL)>J}<4va8B*u^_U5KWgpNfjzgN z(tjHuo~)`L6=7^$JnDsg>!cE>&hq=G&2lWetZR|1?~_jvL+m@zdrOktmo&+h*xf6) zs|R}WS?miZ(StGeru78N-#a9niVB{$Vk;%ORMk8^?PBhSeN}Ho{m7R$$)ikN-2d(U<+_A$?PFOE~NcZ;)o#RzcrdptW| zwfV}Q$3eWkk~g7>M8(O+NmP>_8lz%K;(9@d+2XIdv&L_G4~kynT=f1}Z`nBwwt4n_ s_C5JG0Dj(o)21p&thb?fk$)cWj|gHCYJom3=6@7uIoaO4bNM{vUt|9vcmMzZ diff --git a/faststack/loupe_fail.txt b/faststack/loupe_fail.txt deleted file mode 100644 index c045057874e3dc1574343d28287eb40371339066..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8158 zcmd6sYi|=r6o%(>rT&L)Dujc;4GEMG5hMr(A(aY2Q9t0ya&9J&#Fpc3jMe-ip(tg$owTNcB1 z_%?hKM&VenCgD)9$Kgcq{t%wiuo2even(dkUWYH5@nMR6BecU^jq+Rzw>3VB<3{bB zu&I%Iy4R;;$KgB-bbS%5yZZj4?zM<}EQmuv-WSGk*b<~2J-cB~U-!jxS5)=IBYeW~ zUicK(F@CDCBSGbPthua)j*%b@!$*DD)0(#Kp}0Dptvimh89NY;LVOm2z`qk=K_c8P z`!mtt?>(&?ikFe*_9A`YWwaeJ7y5m!ySSF?eU$cExDkGvQMtV4)5=0LKM60xPvKp7 z6FT9os86F`^M)LI*(=$0RgOLB>|FXAOQxriY)_gwk=_SU;(XnchWDZdd(k$~y}hB= z-KmZAq{(y5+tUjCrpQv!o5}G+dd0TDk7Z4w4Vk5kqdbc6C}WFl;T!2{OM?9FWLCFi z>!0NdcV+jxdTz$Iz9?G@tAdU16>-j*o_5*nr&_fsjob?l^t%=w&**gv!$%IaI$0=R z+$yh2nl0(1NnWe+MRe~}`tM5M`X%ZFo~!ySBpu>TG1DuSa4`LkE*Kr?ziS!3hx(5{ z61n;sH@hh+-dTj!RIkJeX5#s)(wb3}qxE~ieXDts=$_XeolS`h(t9A{5{e3qOkbfxfNk%0X_HbX)Kp7hYi4EGHD>ghhzWf^5#x-@$`im}CVa!0(7cg9h}4nNsu5!cuZw4LLv`ZYJBD$fJO6)bWr zyYEY0Wvll}0eS7qhOxpNL~=cLiI3#QT^1u@n8=H;;HG&#eS>a}XV)THq`PS-P8oGJlNqUP9( zmW0Rb>mT(1bwnMLbK<`VdR|VK!_te@w`B^?Di_BEr{Q~9^V?XbdCcKWCl8OgiJ=R<9O47_#WNmr(NSs(krrNXY ze6BY#fK`1~H#W=N03X+!2 zO;TslT3eRcL=C>&)Cb)U&(sTbWC_*>0U3WMz9BoK+NvC^%%h6;NaZM2kMSuAxNWkl z@k6wLTf$?u#O?{s6<65T@#T(gE zNW3ATWMBE-)+L&c7YROHn(eb;3D&Ln&Vys7?-+<@W>^p0no)xdR_!mRSEpt^!rT;G zcHEN1VLfd{j3ziuev9uB2j@9=Sr#*#@J_S=V^_bOT686k-H@~ygNG+EE?I>1v1Ur0 zbN{^&sjJh56w9(%)O|aYv1Xf}H2X(OQdkskEh0O_oU}bsHN{D)&}Iu{7n5BQ^l}-y z^KRQLt)3;8wY+j~Ml6G-9M5&l%+u{w1Z8*xJt$Ic$$NJ61#fNXa(|uo5WY-eOiG>M zB8``=;wrQxNhf-pqN?WI0xa@W@qHJyR+mY$zQI?{gZ0%Ov$0>IuGPxk)6{1ptB$;q zj%OsPSq7kLr)Go86baGiqWb;v9&?ekgFE#riCeIDG?u%Xau{)>&UK20`B>G23u%=r zMBsbo`MUaW5qEH0kBnuE|3&2p$95L;E^2+OXE%BqRl->Bw&VRw-{`%%IzRAgud5w0 z`pbcy{pkCANhe6ve;cY`*W>ylRhr$HAJ~)feyBT>rk%2EsSmiP8#y=W84ftTTIS*$ zik*GWzf>&v&3?>ZWKTSS(UIPl!N3|Qq^5nUlb08fpB+i;AWGEtx)+}-%&%TN7yEX| z%8- zh&^N>2<20wbh=#DJx}WO%lo)Uak=v`?1$4-YV3rYuhj44xpCY{ zzzU#{Otj1eg?_r%k*y_MSKDRSn#rfS_t&&DSKr;g%6Z*#lC@sg>lyS*HscgK*L!gK NLhPZozq)Lp@L%)D%e(*p From 773d8c2aa8e4d41e107daa91b498c3648eb8fa2b Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 7 Feb 2026 12:54:21 -0800 Subject: [PATCH 3/5] Fix stale image cache after save and harden rotation/crop pipeline --- .gitignore | 7 +- faststack/all_verification_results.txt | Bin 0 -> 2854 bytes faststack/all_verification_results_utf8.txt | 19 +++ faststack/app.py | 140 +++++++++++-------- faststack/imaging/cache.py | 32 +++++ faststack/imaging/editor.py | 106 ++++++++++---- faststack/integration_results.txt | Bin 0 -> 12730 bytes faststack/integration_traceback.txt | Bin 0 -> 16006 bytes faststack/path_check.txt | Bin 0 -> 96 bytes faststack/qml/Main.qml | 2 +- faststack/repro_type_error.py | 26 ++++ faststack/rotation_error.txt | Bin 0 -> 1856 bytes faststack/test_err.txt | Bin 616 -> 0 bytes faststack/test_fail_out.txt | Bin 7464 -> 0 bytes faststack/test_final_fail.txt | Bin 1050 -> 0 bytes faststack/test_loupe_out.txt | Bin 8646 -> 0 bytes faststack/test_manual_output.txt | Bin 370 -> 0 bytes faststack/test_pil_blur.py | 29 ---- faststack/test_results_debug.txt | Bin 35358 -> 0 bytes faststack/test_results_debug_2.txt | Bin 35358 -> 0 bytes faststack/test_results_debug_3.txt | Bin 12004 -> 0 bytes faststack/test_traceback.txt | Bin 4990 -> 0 bytes faststack/test_unif_out.txt | Bin 1050 -> 0 bytes faststack/tests/test_deletion_unification.py | 2 +- faststack/tests/test_editor_integration.py | 21 ++- faststack/tests/test_editor_rotation.py | 137 ++++++++++-------- faststack/traceback.txt | Bin 0 -> 16444 bytes 27 files changed, 336 insertions(+), 185 deletions(-) create mode 100644 faststack/all_verification_results.txt create mode 100644 faststack/all_verification_results_utf8.txt create mode 100644 faststack/integration_results.txt create mode 100644 faststack/integration_traceback.txt create mode 100644 faststack/path_check.txt create mode 100644 faststack/repro_type_error.py create mode 100644 faststack/rotation_error.txt delete mode 100644 faststack/test_err.txt delete mode 100644 faststack/test_fail_out.txt delete mode 100644 faststack/test_final_fail.txt delete mode 100644 faststack/test_loupe_out.txt delete mode 100644 faststack/test_manual_output.txt delete mode 100644 faststack/test_pil_blur.py delete mode 100644 faststack/test_results_debug.txt delete mode 100644 faststack/test_results_debug_2.txt delete mode 100644 faststack/test_results_debug_3.txt delete mode 100644 faststack/test_traceback.txt delete mode 100644 faststack/test_unif_out.txt create mode 100644 faststack/traceback.txt diff --git a/.gitignore b/.gitignore index 7ebe831..f5a9bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,8 @@ test_report*.log test_results.txt smoke_test_output.txt verify_result.txt +faststack/*fail*.txt +faststack/*final*.txt # Same junk when produced inside the package dir faststack/debug_*.txt @@ -87,8 +89,3 @@ faststack/test_results.txt faststack/smoke_test_output.txt faststack/verify_result.txt - -# Local test/output artifacts -test* -faststack/*fail*.txt -faststack/*final*.txt diff --git a/faststack/all_verification_results.txt b/faststack/all_verification_results.txt new file mode 100644 index 0000000000000000000000000000000000000000..09488378c802547ecccdd74e72175348ad68eabe GIT binary patch literal 2854 zcmd6pTTk0S5QXQtQvZWr`qo5=Lx3XjfO?UT`cRd=5o84?m{8{;Cn@F6xBbpI#xBH- z8WAa4dFA!a&YUwl7ytfsYTJ&$7B;jwbHJ>(xs7aU6JIZ^Zy9@YR@+)Qwb%B_W|pFB zVK?Z`Y>d8p>`bg)4I7->$?r_=Jm%(dR}B}Q|TXNpHtx2JQdOKVRa zPB_){5xCZJcL=HgUmY-ykRGCC?plPPh>JOFi}wf_F_Fa#e~6v@CriTHbbKcuEl-p( zI;%4|?p#;GDgu{oLpQ4^IK#Ua_R&7Ds*X!v9oZYZa+HOQ-60gN6m(1Gl4WGqu;{}# zCR&C1(qpvck=G(B8$4IfxCpQ z`Q*c{?Y7>j_TI0#zpAI|--YmS#}!Qox5p@iM;j|v*`xA47I}=R&lNqU>}=oswiet{ z*$KaLi`70@-vQVAJ4d;xChZpG`uJ3rOZvQAZzCB-?Pk(!OOjhN8 Tu0pG<8gd`{8yk3L>3`-AS!wS3 literal 0 HcmV?d00001 diff --git a/faststack/all_verification_results_utf8.txt b/faststack/all_verification_results_utf8.txt new file mode 100644 index 0000000..2f22428 --- /dev/null +++ b/faststack/all_verification_results_utf8.txt @@ -0,0 +1,19 @@ +============================= test session starts ============================= +platform win32 -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0 -- C:\code\faststack\faststack\verify_venv\Scripts\python.exe +rootdir: C:\code\faststack +configfile: pyproject.toml +collecting ... collected 14 items + +tests\test_editor_rotation.py::test_rotated_rect_edge_cases PASSED [ 7%] +tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[100-100-0] PASSED [ 14%] +tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[200-100-45] PASSED [ 21%] +tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[1000-500-15] PASSED [ 28%] +tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[500-1000-15] PASSED [ 35%] +tests\test_editor_rotation.py::test_rotate_autocrop_rgb_behavior PASSED [ 42%] +tests\test_editor_rotation.py::test_boundary_clamping PASSED [ 50%] +tests\test_editor_rotation.py::test_integration_straighten_modes FAILED [ 57%] +tests\test_editor_rotation.py::test_rotate_cw PASSED [ 64%] +tests\test_editor_rotation.py::test_rotate_ccw PASSED [ 71%] +tests\test_rotation_unittest.py::TestEditorRotation::test_rotate_cw PASSED [ 78%] +tests\test_rotation_unittest.py::TestEditorRotation::test_straighten_angle PASSED [ 85%] +tests\test_editor_integration.py::TestEditorIntegration::test_missing_methods diff --git a/faststack/app.py b/faststack/app.py index 43da7f3..d0e20ad 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -171,7 +171,9 @@ def __init__( self.debug_cache = debug_cache # New debug_cache flag # Ensure clean shutdown of background threads - QCoreApplication.instance().aboutToQuit.connect(self._shutdown_executors) + inst = QCoreApplication.instance() + if inst: + inst.aboutToQuit.connect(self._shutdown_executors) self.display_width = 0 self.display_height = 0 @@ -1031,40 +1033,50 @@ def _on_save_finished(self, save_result: dict): if editor_still_on_same_image: self.ui_state.isEditorOpen = False - # 2. Clear Editor State (release memory) - self.image_editor.clear() + # 2. Clear Editor State (release memory) - only if still on same image + if editor_still_on_same_image: + self.image_editor.clear() - # 3. Refresh List (to see new file or updated timestamp) - self.refresh_image_list() + # 3. Refresh List and Handle Selection + if editor_still_on_same_image: + # Full refresh to see new file or updated timestamp + self.refresh_image_list() - # 4. Find and Select the saved image - new_index = self.current_index # Default to keeping selection if not found + # 4. Find and re-select the saved image + new_index = ( + self.current_index + ) # Default to keeping selection if not found - # Try to find by exact path match - if saved_path: - try: - target_resolve = saved_path.resolve() - for i, img in enumerate(self.image_files): - try: - # Robust path comparison - if img.path.resolve() == target_resolve: - new_index = i - break - except (OSError, RuntimeError): - # Fallback to string compare - if str(img.path) == str(saved_path): - new_index = i - break - except (OSError, RuntimeError): - 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.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() + # Try to find by exact path match + if saved_path: + try: + target_resolve = saved_path.resolve() + for i, img in enumerate(self.image_files): + try: + # Robust path comparison + if img.path.resolve() == target_resolve: + new_index = i + break + except (OSError, RuntimeError): + # Fallback to string compare + if str(img.path) == str(saved_path): + new_index = i + break + except (OSError, RuntimeError): + 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.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + else: + # User navigated away - skip full refresh to preserve their selection + # Just clear stale cache entry for the saved image + if saved_path: + self.image_cache.pop_path(saved_path) self.update_status_message("Image saved") else: @@ -2840,6 +2852,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: self.prefetcher.cancel_all() if self.image_files: self.prefetcher.update_prefetch(self.current_index) + self._rebuild_path_to_index() # Keep path->index map in sync self.sync_ui_state() # NOTE: Thumbnail model refresh is deferred to Phase 4 to avoid disk rescan @@ -2860,20 +2873,25 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: raw_path = img.raw_pair try: + # Check RAW existence BEFORE any moves (existence changes after move) + raw_exists = raw_path and raw_path.exists() + + # Step 1: Move JPG first 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: - # Check for partial failure: RAW existed but failed to move - raw_needed = raw_path and raw_path.exists() - raw_failed = raw_needed and not recycled_raw + if not recycled_jpg: + # JPG failed to move - don't attempt RAW, add to failed list + log.error(f"Failed to recycle JPG: {jpg_path.name}") + failed_recycles.append(img) + continue - if raw_failed: - # Atomic unit behavior: undo JPG move and treat as failed + # Step 2: Only move RAW if JPG succeeded and RAW exists + recycled_raw = None + if raw_exists: + recycled_raw = self._move_to_recycle(raw_path) + + if not recycled_raw: + # RAW failed but JPG succeeded - atomic rollback log.warning( f"Partial recycle for {img.path.name}: JPG ok, RAW failed. " "Undoing JPG move to keep pair consistent." @@ -2908,21 +2926,19 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: # If undo failed, permanent delete can't act on it properly if undo_succeeded: failed_recycles.append(img) - else: - # Full success (JPG moved, and RAW either moved or didn't exist) - record = ((jpg_path, recycled_jpg), (raw_path, recycled_raw)) - self.delete_history.append(record) - self.undo_history.append(("delete", record, timestamp)) - recycled_count += 1 - # Use resolved path as key for robustness - resolved_key = img.path.resolve() - successfully_deleted[resolved_key] = { - "jpg_moved": True, - "raw_moved": recycled_raw is not None or not raw_needed, - } - else: - log.error(f"Failed to recycle JPG: {jpg_path.name}") - failed_recycles.append(img) + continue + + # Full success (JPG moved, and RAW either moved or didn't exist) + record = ((jpg_path, recycled_jpg), (raw_path, recycled_raw)) + self.delete_history.append(record) + self.undo_history.append(("delete", record, timestamp)) + recycled_count += 1 + # Use resolved path as key for robustness + resolved_key = img.path.resolve() + successfully_deleted[resolved_key] = { + "jpg_moved": True, + "raw_moved": recycled_raw is not None or not raw_exists, + } except (OSError, PermissionError) as e: log.warning(f"Recycle exception for {jpg_path.name}: {e}") failed_recycles.append(img) @@ -2991,6 +3007,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: self.prefetcher.cancel_all() if self.image_files: self.prefetcher.update_prefetch(self.current_index) + self._rebuild_path_to_index() # Keep path->index map in sync after rollback self.sync_ui_state() # --- PHASE 3: Status messages (immediate feedback) --- @@ -4768,7 +4785,7 @@ def execute_crop(self): # Invalidate cache and refresh display self.display_generation += 1 - self.image_cache.clear() + self.image_cache.pop_path(saved_path) self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() @@ -4976,7 +4993,7 @@ def quick_auto_levels(self): ) self.display_generation += 1 - self.image_cache.clear() + self.image_cache.pop_path(saved_path) self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() @@ -5052,7 +5069,7 @@ def quick_auto_white_balance(self): # 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 - self.image_cache.clear() + self.image_cache.pop_path(saved_path) self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() @@ -5476,3 +5493,4 @@ def cli(): if __name__ == "__main__": cli() + diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py index 40b6128..c0eb764 100644 --- a/faststack/imaging/cache.py +++ b/faststack/imaging/cache.py @@ -66,6 +66,38 @@ def clear(self): finally: self.on_evict = callback + def pop_path(self, path: Union[Path, str]): + """Targeted invalidation of all generations for a given path. + + Hardened to handle both Path objects and string keys, and resolved paths. + Expected type: Union[Path, str]. + """ + targets = {path, str(path), str(path).replace("\\", "/")} + try: + # Handle Path objects and ensure we check the resolved variant + p = Path(path) + resolved = p.resolve() + targets.update({resolved, str(resolved), resolved.as_posix()}) + except (OSError, ValueError, TypeError): + pass + + keys_to_remove = [] + # Use list(self.keys()) to avoid mutation during iteration + for key in list(self.keys()): + key_str = str(key) + # Match exact path or path::generation pattern + for t in targets: + t_str = str(t) + if key_str == t_str or key_str.startswith(f"{t_str}::"): + keys_to_remove.append(key) + break + + for k in keys_to_remove: + self.pop(k, None) + + if keys_to_remove: + log.debug(f"Invalidated {len(keys_to_remove)} cache entries for path: {path}") + def get_decoded_image_size(item) -> int: """Calculates the size of a decoded image tuple (buffer, qimage).""" diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index b17c923..1e9820c 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -228,8 +228,8 @@ def _rotated_rect_with_max_area(w: int, h: int, angle_rad: float) -> tuple[int, wr = (w * cos_a - h * sin_a) / cos_2a hr = (h * cos_a - w * sin_a) / cos_2a - cw = round(abs(wr)) - ch = round(abs(hr)) + cw = math.floor(abs(wr)) + ch = math.floor(abs(hr)) cw = max(1, min(w, cw)) ch = max(1, min(h, ch)) return cw, ch @@ -270,17 +270,21 @@ def rotate_autocrop_rgb( # Center-crop to the inscribed rectangle cx = rot.width / 2.0 cy = rot.height / 2.0 - left = round(cx - crop_w / 2.0) - top = round(cy - crop_h / 2.0) + left = math.floor(cx - crop_w / 2.0) + top = math.floor(cy - crop_h / 2.0) right = left + crop_w bottom = top + crop_h # Small inset to remove any bicubic edge contamination - if inset > 0 and (right - left) > 2 * inset and (bottom - top) > 2 * inset: - left += inset - top += inset - right -= inset - bottom -= inset + # We skip this for exact 90-degree increments as there is no edge contamination. + is_exact_90 = abs(angle_deg % 90.0) < 0.01 + actual_inset = 0 if is_exact_90 else inset + + if actual_inset > 0 and (right - left) > 2 * actual_inset and (bottom - top) > 2 * actual_inset: + left += actual_inset + top += actual_inset + right -= actual_inset + bottom -= actual_inset # Clamp defensively left = max(0, min(rot.width - 1, left)) @@ -615,6 +619,29 @@ def _apply_edits( # Alias arr = img_arr + # ENSURE we are working with a float32 numpy array + if isinstance(arr, Image.Image): + arr = np.array(arr.convert("RGB")).astype(np.float32) / 255.0 + elif not isinstance(arr, np.ndarray): + arr = np.array(arr) + if arr.dtype == np.uint8: + arr = arr.astype(np.float32) / 255.0 + elif arr.dtype == np.uint16: + arr = arr.astype(np.float32) / 65535.0 + else: + arr = arr.astype(np.float32) + # Heuristic: only scan for max if necessary, or use a sample for speed + # If the first few thousand pixels are > 1.0, it's likely 8-bit data. + if arr.size > 0: + sample = arr.reshape(-1)[:2000] + s_max = sample.max() + if s_max > 1.0 and s_max <= 255.0: + arr /= 255.0 + elif s_max <= 1.0: + # Double check full array only if sample was small or ambiguous + # but typically 0.0-1.0 images stay 0.0-1.0. + pass + # NOTE: For UI analysis, we want to capture the state AFTER White Balance and Exposure # but BEFORE Highlights/Shadows/ToneMapping, so the indicators reflect the # "available headroom" and "current clipping" accurately for the recovery tools. @@ -679,8 +706,11 @@ def _apply_edits( right = left + cw bottom = top + ch - # Apply inset (2px) to match legacy behavior and avoid edge artifacts - inset = 2 + # Apply inset (2px) to match legacy behavior and avoid edge artifacts. + # Skip for exact 90-degree increments to preserve full dimensions. + is_exact_90 = abs(straighten_angle % 90.0) < 0.01 + inset = 0 if is_exact_90 else 2 + if (right - left) > 2 * inset and (bottom - top) > 2 * inset: left += inset top += inset @@ -706,22 +736,46 @@ def _apply_edits( # to the expanded canvas space. if apply_rotation and abs(straighten_angle) > 0.001: - # The original image (orig_w x orig_h) is centered in the - # rotated expanded canvas (new_w x new_h = arr.shape). + # Transform crop box through rotation: + # 1. Convert 0-1000 to pixel coords in original image + # 2. Rotate corners around original center + # 3. Translate to expanded canvas new_h, new_w = arr.shape[:2] - - # Calculate the offset: the original image's center is at - # the new canvas's center, so the top-left of the original - # image is offset by (new_w - orig_w)/2 and (new_h - orig_h)/2 - offset_x = (new_w - orig_w) / 2.0 - offset_y = (new_h - orig_h) / 2.0 - - # Convert crop_box from 0-1000 to pixel coordinates in - # original image space, then offset to canvas space - left = int(crop_box[0] * orig_w / 1000 + offset_x) - t = int(crop_box[1] * orig_h / 1000 + offset_y) - r = int(crop_box[2] * orig_w / 1000 + offset_x) - b = int(crop_box[3] * orig_h / 1000 + offset_y) + orig_cx, orig_cy = orig_w / 2.0, orig_h / 2.0 + canvas_cx, canvas_cy = new_w / 2.0, new_h / 2.0 + + # Get crop corners in original pixel space + c_left = crop_box[0] * orig_w / 1000 + c_top = crop_box[1] * orig_h / 1000 + c_right = crop_box[2] * orig_w / 1000 + c_bottom = crop_box[3] * orig_h / 1000 + + # Define the 4 corners, rotate each around original center + corners = [ + (c_left, c_top), + (c_right, c_top), + (c_right, c_bottom), + (c_left, c_bottom), + ] + angle_rad = math.radians(-straighten_angle) + cos_a, sin_a = math.cos(angle_rad), math.sin(angle_rad) + + rotated_corners = [] + for px, py in corners: + # Rotate around original center + dx, dy = px - orig_cx, py - orig_cy + rx = dx * cos_a - dy * sin_a + ry = dx * sin_a + dy * cos_a + # Translate to canvas center + rotated_corners.append((rx + canvas_cx, ry + canvas_cy)) + + # Get axis-aligned bounding box of rotated corners + xs = [c[0] for c in rotated_corners] + ys = [c[1] for c in rotated_corners] + left = int(min(xs)) + t = int(min(ys)) + r = int(max(xs)) + b = int(max(ys)) left = max(0, left) t = max(0, t) diff --git a/faststack/integration_results.txt b/faststack/integration_results.txt new file mode 100644 index 0000000000000000000000000000000000000000..53f7d0426d485f4575b16dc0ab0ab32cabe82b23 GIT binary patch literal 12730 zcmeI2+fEx-6o&V8rM|;Rr~;(M;Z#bZBvnK}jg$lu()I#Ui(mt89E@xyfU3TF+yC3k ziy4o{V{9V9#b{)EybtSq+H2;&f1id~uO#$CGxYSG=$pG{MfVSMC1E#QX~pRP`)a6#WzF)v6n@nFd7L+D zAA}9ftm(5dm}`eG;Y8P!U@dEWU7w|E*qh;TI0#3A-4K@tqT^6p!5KUrg-;r*g|C`B z)AvEx(^yv&p2sx@1KdViO{*^Tfv>J0cC@CUwO!#z!emVBgOW2*(Gtacw*`;g@OU1f z{-u#4L9Xf3k;K{&AN$&IHFHStM7wJ7N+Q&BwkfKTDBqUWHU*;>VSwMY-V?8WU)LJ= z0ADxMBXs6Y^bg&cUDP6tk45c!%^|<7%&wQBXmk&&Q!Jka$?Od3>!PcvS!CPLcoMe5 z^YAjf65JR1x3AAn(&P`}L!{o}+y&)$NM97Yoa&MV9&$L~p&8|NGLV+(CeIDloN|LM zX7{XgXDYSd!aGTBPf|-~Z*vEQ^@&$9g*9N&W;y7-+!iKdu3y_U+G)$1;5-RWGFV(+ z<#zpkK-FY>PNU{)?Qz?En!#Ak*RyMypNUegU*d(V`8mE-%G+WZF-p|NHuJndP-V8sTg5%X3{d#TR}zC0C?O47aG+4GZG? zQZaK?QeRgd*^ta1DbK8Fd{HtW4kmF8UThbuH^a}0Tbps+nszM(({W3hJXDlxN`jUn z%sT&w(X}DW7m9hi5$>t5&x^WwVItp=i%#OYrv8~tzmCvaqVBc!lD87AXlQm`RIe#g zZs=Oq-MVOi?nkbHetkC?+R%23eCzXGhXI7A+ldf(=++*b|0+#8ga!#WJv; z4q)QJ)E!6KWjWfovs!}Dt}d*Gs^K`UVvJZ@Q+z&+_t3CAkj_>P-a@q2OX0lGimt2+ zDfDDr%aX*+>4Zl(rn)&5HDy{zrC!7_dN|8S4KH{j+Rik7b9#^#yinBwVQGrJ=*|3$ zoy2-1nrgD9O2t-SE#)-1CR6KpH-L2|4VfO{jM{D?`Xc!Z8?eaLR%}k~A%#+pG-0=G z?VLmf^<`64ZcE=6suql!X($+mP=>uo9{9>Y_D(G_QBk_TrL+eakw$V!|+ zvmZ5wrl?mtaR>EpR%4SSQ_sM^-@fK@*p9ptbBN@P308#PWO?M|K|J@}!}1xYyWJuC znXJJgIG!c2?YuSJZ^`GWy=|<^!z+q-!@1cbrvH}7a{9=Zr{`P7zq;a+ox3e&5ZTEv zSZ>}9EK+-{hu1lM_TZfVyG^#Ss6{+l< zhV6)%b2qdkiZ@g!Py}yR+IggV>K3X>k4i+ZhA`1punNF2JHAPj!u4_Fjh*SJ8P9F%f6r z8@f5`mq~a>E6H`lds|DCG0)fxziWILW+HfCLkCv5<-1u~inI5lK9uH_;fj59{N`}H z!_$jrs#j5}eRVcoUgt34IQsb@4$$F#yfd4(IV{Zef}K-kW_8Dib=j^aR3TIoSY|ed z&PLg$Ah``f3wB7&ou!XlOcm_5R4z5^sF1Vk(Yx%#L6o?RvU?%UsgCvwd30@NPZ(HT zzi_q7`tR_M=rdWr6Q-UhZpZ2b@1Vm2J#;BKtpm^KNu|G|8ifiO&N+8{k|fRxai+0) zxL(imX;j~=TKyuLES_eg17}WZ$!zcLX7H%1z>nt|w|Fb3Dtyq-V%X5WPHQ`gaNetQ ziF(v7*ERGFpKr4x<$H@ye#YOpIn(eP$>c)3j9bOIg%f)gfA-#OY)&w?!~s<|eo4f~ zgAX-kaf__tv7HD-XUem|_!`xEwB8qTY;R+?0zAbh<66`dF#-HULOZ)23L~{}U+2G$ zw1us3IxIVDbL9GVzYcaZ&`$WqF9!Hel}}&bwi#*A9Zqf^+oKm($&2C?kZHT z$?I1{OVwSa7QjNV6pz+bceU!Sx-IbQ5rmG9_d)3gRNYl1lI`#2J1|sz#l79EzqzdE zBSJ}BpVNsQ7B{NyDzUceuF@O!j&0Rl zwccgbU9}pc>aNz+AFR5o!|xDP-PNkQ$`h%(?5@uCo~u7)IszV1GCBF5YD>e?#?t(K>w(82V?^lI^nh=IL_Ub6K7QqJQ2UtLdG} N6Ew@PEtH@!|jg literal 0 HcmV?d00001 diff --git a/faststack/integration_traceback.txt b/faststack/integration_traceback.txt new file mode 100644 index 0000000000000000000000000000000000000000..efb9cdbbd583b5ef32364d2466c8bf43ca580b97 GIT binary patch literal 16006 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 MF4m>LW(GL?7kTQ`kpKVy literal 0 HcmV?d00001 diff --git a/faststack/path_check.txt b/faststack/path_check.txt new file mode 100644 index 0000000000000000000000000000000000000000..e2538979c38e7491cf14a57be7dfeb68fb3bcdb9 GIT binary patch literal 96 zcmezWFPXuLA%-EDA)g_IAr;6|4&|@fIsAS+};9>v(JV+E9 literal 0 HcmV?d00001 diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index b884134..f9df1a2 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1364,7 +1364,7 @@ ApplicationWindow { highlighted: true Material.accent: "#e57373" onClicked: { - uiState.cleanupRecycleBins() + if (uiState) uiState.cleanupRecycleBins() allowCloseWithRecycleBins = true recycleBinCleanupDialog.close() Qt.quit() diff --git a/faststack/repro_type_error.py b/faststack/repro_type_error.py new file mode 100644 index 0000000..63e107d --- /dev/null +++ b/faststack/repro_type_error.py @@ -0,0 +1,26 @@ + +import sys +from pathlib import Path +# Ensure we can import faststack +sys.path.insert(0, r"C:\code\faststack") + +from faststack.imaging.editor import ImageEditor +from PIL import Image +import numpy as np + +editor = ImageEditor() +img = Image.new("RGB", (100, 100), (255, 0, 0)) +editor.original_image = img + +print("Calling _apply_edits...") +try: + res = editor._apply_edits(img) + print(f"Result type: {type(res)}") + if res is not None: + print(f"Result shape/size: {getattr(res, 'shape', 'N/A')} / {getattr(res, 'size', 'N/A')}") + else: + print("Result is None!") +except Exception as e: + print(f"Caught exception: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() diff --git a/faststack/rotation_error.txt b/faststack/rotation_error.txt new file mode 100644 index 0000000000000000000000000000000000000000..ab0537e429550ea97958b317ac0194622549ffd9 GIT binary patch literal 1856 zcmb`IPjAye5XIjaiSMwS%7MB~gAh~{A)y?&0HW83C`!^2>N=5=0O7*}zc;p(KNJxR z8trPmvorH%=I#2&_Y1X^q@^ZWFehfO7Mg0NBEFXzszkTot8Ih}-Pb+MmE&uvD}0x_ z#org=6zb}X^)Zt4PHRqltfP08se{(@sUD%9hrU94tY>IW8E3UF*GkvO8ebjupM<{g z{YvNRYlQDHS@yv(Br9j;97nqO&E98Dmms~u??C=4nESfK#{2W|(XX*p*fUOjWjODW zh%?TNIa`7u>CZ@PXQTyDr)1sA1xqS#TSDKHqq@La*ZyA@w+1CBb+6V`C$acK_3N-ck zl?^9Rk%}Rk^?<6lldgD4EZ4{Uai!g_Ec%n~Kw)G?*xa@S9BS5wc|F%-#2hf{r04X@ zY+gmTUxfF>zU5`j$Lg+Rob)}u34C4+*_jaE4F1OI;f{6Ehb=McUhVfyZP~-J;)d}Q z^={J9PTV7O)8JGQbxNR}1e=SfV|z~dt@2&-+qCYNw4XW`R2Z@=#tmtVAJkhcaK(_X>P{;ds6)w4vFbA?K@ocYyTx` SxbJtrtEa)OcAagkjPNIvh8&sz literal 0 HcmV?d00001 diff --git a/faststack/test_err.txt b/faststack/test_err.txt deleted file mode 100644 index e09c30143752ae2a477a733d0691c39df2485e1f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 616 zcma)(%}T>S6ot=P@Ex*Ll!`995TvxwO+j6Xl+a9TplM4w72Wvg>UX9cY8FBnlHA-m zKX=ajd~furs5h;(QlUOiPv<(*1KmPP)${}XTmEL?O10_Fm@9f+egwOPxz!EteLYQj zK_6(%HAQ8u23`rGm-vP5bdQ!bkF0=G3nIddY0rLNCoJonf-~kntoy}b*>{lU4O%dL zOJ-wonNu6{7n?cx>qNaj0UPzqc>@VJiJGcPiD#^F7o4~cF5=?s7rI2@MX$O(C_TYz zl3L~k4asOme*>>(Z`GN}^ILnQ9XGmU`bVlV-EN*^n9}Z^r{$Ct+~XO1XSLpO8sO5f SO1vuk^DOr9y*VYFKpeC*wp$q(LK?fe-j(ng^m1qYF#T-H__9& zBfPMC_JfUWC|Of`C)tI4l)Qg5&dBzxt^bcz68p_Qsm1wh^gT;$SGC-C?2+onULVjt zwr8r@*VUfY4eip-R6a@8uHHYb@IG~%&xN?DZ(sf1dh1-)?5OWYJ)KGJL{AgR%vFM~VfB~N_K6+ak&ynW zYgcxCV1GH*DF60wp1ECnLV_oR*@77F=PBqltbjVUMA!ntw!Ck12**Of8tF>+_~Y~| ztavYWM$7naXJ)NZ>xsRVUVpVW6}|3jt!xf8E2(hXPIWJY932mZj8z#uwMnC;UJvB0 zHzP#erMwS|RqeOfBXdUuuAtG4b)WchrG4!Y z&+Op4%mOYMIR^~2qUyMUH-wc2^Y2_Egtyj@LS@f##8dlS>*GWi%Gz)F2g1%$rHs2B z_vUY3@5_yQd)$w04m@VDwr_hr;EbLymbXmhE$8;o?z^AYBX_F$u}Z2oF{+}^dK6m3 zs`ovQfFA;X5&f{}oAkw>nG{%^Zm1-nPLd@L=%~!H5m^vw5$2kvzoQlpZ3I&ZSOx&q{^g6a*3%* z$l0Q_LUQO5Y^i$Ohb|1LkB=Qq%i7wyDwpjI8Pxwcnl}fK;>7Q~_ zOKkmH*IF*CStVC!#(_=a>aAzwi}Es< zV861iqm+6Ux2gG5J-8Z8c)#VsDuZshFvOiL7v44(F3P_G|8WwuPT|}ICNKA%M1Ia? z*e}M@Eo#T&^L0j`4}5W?sP@WE?H6aWL+4(i0e-_-Ovs)NH7@&1qG33ZDH?juZ_8N@ ztd3-M(u~~9$<1%qix~BNS+nGMZ$TJYebv}-pscUV`J$9nVnY=7xfV0M3D|GbGDc+2~+O+9L?#{27?ND=?|UcvX9G8*vqQoAiqmkaH9 z!akd?GV7j!F}wN+UgYn36e#yCtAATquTpW*vOc_O|n&P6+!`QJP?vlsimxT|rM yyy?rmeE9MN#?`$v5%;o}4=31UIh^0@>mpY3B{F=$3K==y^xEpdzI^quK=?mmN&AZc diff --git a/faststack/test_final_fail.txt b/faststack/test_final_fail.txt deleted file mode 100644 index da786409346540215b02777e5c6d00b406aa8272..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1050 zcmcJOTT8=05QWdP;D6W`-)!hbR0@3%^vC$9sbP2Qc51ZHN~OQInyE(DaN9M)MElxPr2?#0cVO3Ag7-q4QX>sH z_gGF>T4QnFMIR~B0IltP9ip$iKCtcS6wMJb>~w{ml(W`g4R}9B`k&b68Y@-`_M9wZ zc+AMkGP4|0J@OXm8C}U9Ysy;VylN0v*vzr5iQ)8nB=a*SB`g*& zw!Hw)ye-GdqdxGGf*c_$DpvS-=2v)Y(^&;`yduAxN8M^OSUH^_3v4qm8joS|H(hVY zb#qR9ZH$Q_8^45_mYj`Y>iZYoBhPaT-$~L=6*R5^Uov+>pawoNr diff --git a/faststack/test_loupe_out.txt b/faststack/test_loupe_out.txt deleted file mode 100644 index 44a6443aa2fec6f4d68c5dd58838b9ae910177ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8646 zcmd6tZEw^@5Xbj*rG5uD2<3vnO~OL~HI-5b2nni)6!k@~vclzoz~yqfOIlFXuip0m z+qucwKA-P$(1I*GKJR*-cIH1b>-_WgR#^7P!g)9hV_h>{xf_R*a25vf_j%Y4BaMyq zYgrCk;p^~K7>2%RorfdQ9)(lU`$K#N;da>6{Vg3?*bN^v-ZpA8+v|E=lUh>N8w@UhMs62NXxF|*q2slhK{}PPS5i2 zyT%5(cEg^YjU?eP&go8gn{2sez1N9eBT+oloCD1riANUZbK*TD8Ayr~NzAn`dc2K} z!$|e5p7caH*Lfz3^&>yt>&MxQVZmd)mB%BCRMY68q{?LBO!HXfQ)vrtPG9x0boKi} zbkAhPq2?CRPDsUQ9_4x{3DaEc=;MVrUr6eao((jo5XV#5^;o})__QazdU1XsDK2#1 z(-_{Cg~wqh{1{$^rxW`@1z&OPw#9uYyJE5PXdBaU6g8>7J$o+JamrW*E9{1Ct@NJw zKmP1i!6Wg5BKjs8zMgpImt?Ugve>im{IgiBCwmN{-}JvIufda+N5}ML7Tmh8!b@3r zPgO61n_y7Fu+a z58f+ZY$$hZhP(Q86dvg0ZcTK-oGi{+*VPW2eMfUPHTQ1#PVcUVAEKr+McwGf}0Gv9WX0Z1T>rR@gk{S(Jq= z^dw^Iq4@8{x8LY)B~pPN^}<_8f2?O~I+`fAduA^BDdTxYx4J0{zs6DI`fVxN)7f

eMHkvo?G)1fOxL;x*m}ArHc^~skNG@^WtXW_(ZK!G|5B3@ zVTH~k9ab4_X)Vp)PGTitb?c6Fp(-226?Xc`H?#PT&0w{gbgMpXno+eU^&lQOlHczu zKFVJ2)im}xkPlNcHBqDZ?|*YF({oQ*{4V)^M4s zQZ!p-7RLMf*KJ&_pz|14i`4O*TCB9KaGj^GD}G1KPo&T#P|;Z}f1qFFd%A);C)dRP zChE<0T8v8(J5VC7bBWILXjTP8(5u$8Ar(vu8T{_hlZRf>Ox zzak%UDDlj`hQ$uP>j<$dU*pOS5BbV+bDi$4_*JXcChy8(Rhg>1j%>0fYZwPA6R$h! zapBfKk^~?eI~!nK7JiCZd1jk5^ZJ|(?S|kF z-seD>cn9CGok?v~3|{7_;yq9q#p)QJP{4f?ANw|Xzzy*+U*p*g9c6EsmFKK;DD$Kn zc_UhMHrydM)&;wgN3jFRGpdk%MFX>3y7b<7;*%$eq7%&l(@n~Yh<@xyjb-t&9m@A^ zo0obE{0A?g!0K=7o+sqOdhJStT0d#>yJhTMqg15YormC3VgH@AW8MT{g=g34LdE*G^?5`&*o} z$45t2n1#27$WF1z+KyChI7t=S?t$#5v-{4vEaG?GP1|ltJxeU>dF5UVEJLOyo$D(z zEw_6Sk`WQ~m{_@^=y{|kRO+492w}C)BPOLzaMp@XM@-p!M(j1T3>dLzZ81hv4 zeG^x$Zj<))hF>`k*57u_#(zOwtChW{sn5n%JBms=9=vCQ#PRE}tD z-wa>HRUhfvjnPJxFw(v4Xg`iIxv$^Ph3)41UG0F;UmokaA7h^<=__jWm(};Yf1*mW z8}kEuPTmi7FRkg{`yRwz06Vv>GaB#(ciDq)rtIE(4yJ0Mq6J$`&%W-KrOtau_=|LS zsoM3K&ZnYhyW7O5#VNG`6*pfG9!~zc9X7S^&zXrrb*J1h-6}(?IKV*ayz;&>HuSna z^|R%@NMu~@8xPrsFVobKi8oKFQNi+2?3TeKBn0`FIgrr5Q1Qhy@w!?k{b{};yXEJ9 hFI~>E*6%qN))&O)zZIJ5YrY_?t3#xIy}E6Y@L!9(J1zhK diff --git a/faststack/test_manual_output.txt b/faststack/test_manual_output.txt deleted file mode 100644 index 021eb2ab7817e0e18b62afc5fb9d741543ef5b98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 370 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

ThZq+vLS|i7Hx1hEGecjf48JD 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 YJT^OAZF;TGR%M&rr0v$W>dsg18*7cViU0rr diff --git a/faststack/tests/test_deletion_unification.py b/faststack/tests/test_deletion_unification.py index d993331..d8301e6 100644 --- a/faststack/tests/test_deletion_unification.py +++ b/faststack/tests/test_deletion_unification.py @@ -190,13 +190,13 @@ def test_delete_current_image_triggers_batch_dialog(mock_controller): # Mock a batch containing the current image mock_controller.get_batch_count_for_current_image = Mock(return_value=5) mock_controller.main_window = Mock() + mock_controller._delete_indices = Mock() mock_controller.delete_current_image() # Verify dialog was opened instead of immediate deletion mock_controller.main_window.show_delete_batch_dialog.assert_called_once_with(5) # Ensure _delete_indices was NOT called (deletion is deferred to dialog) - mock_controller._delete_indices = Mock() assert mock_controller._delete_indices.call_count == 0 diff --git a/faststack/tests/test_editor_integration.py b/faststack/tests/test_editor_integration.py index 9dc4525..b0f04ff 100644 --- a/faststack/tests/test_editor_integration.py +++ b/faststack/tests/test_editor_integration.py @@ -26,14 +26,25 @@ def setUp(self): patch("faststack.app.SidecarManager"), patch("faststack.app.Prefetcher"), patch("faststack.app.ByteLRUCache"), + patch("faststack.app.ThumbnailProvider"), ): 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_edits = {} self.controller.image_editor.current_filepath = Path("test.jpg") self.controller.image_editor.float_image = MagicMock() self.controller.image_editor.original_image = MagicMock() + + # Initialize state for delegation tests + self.controller.image_files = [MagicMock(path=Path("test.jpg"))] + self.controller.current_index = 0 + self.controller.auto_level_threshold = 0.001 + + # Mock returns for methods that unpack results + self.controller.image_editor.auto_levels.return_value = (0, 255, 0, 255) + self.controller.image_editor.save_image.return_value = (Path("test.jpg"), None) def tearDown(self): self.config_patcher.stop() @@ -54,14 +65,20 @@ def test_missing_methods(self): # 2. rotate_image_cw try: self.controller.rotate_image_cw() - self.controller.image_editor.rotate_image_cw.assert_called_once() + # AppController delegates rotation via set_edit_param("rotation", ...) + self.controller.image_editor.set_edit_param.assert_any_call( + "rotation", 270 + ) except AttributeError: self.fail("AppController is missing method 'rotate_image_cw'") # 3. rotate_image_ccw try: self.controller.rotate_image_ccw() - self.controller.image_editor.rotate_image_ccw.assert_called_once() + # AppController delegates rotation via set_edit_param("rotation", ...) + self.controller.image_editor.set_edit_param.assert_any_call( + "rotation", 90 + ) except AttributeError: self.fail("AppController is missing method 'rotate_image_ccw'") diff --git a/faststack/tests/test_editor_rotation.py b/faststack/tests/test_editor_rotation.py index 6994d1a..118b114 100644 --- a/faststack/tests/test_editor_rotation.py +++ b/faststack/tests/test_editor_rotation.py @@ -1,5 +1,6 @@ import pytest import math +import numpy as np from PIL import Image from faststack.imaging.editor import ( _rotated_rect_with_max_area, @@ -18,8 +19,8 @@ def test_rotated_rect_edge_cases(): # Near zero angle (should be close to original dimensions) w, h = 100, 50 cw, ch = _rotated_rect_with_max_area(w, h, 0.0000001) - assert cw == w - assert ch == h + assert w - 1 <= cw <= w + assert h - 1 <= ch <= h # Near 90 degree angle (should swap Dimensions roughly) # The function expects radians. pi/2 is 90 degrees. @@ -99,8 +100,12 @@ def test_rotate_autocrop_rgb_behavior(): 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) + # Allow small tolerance for interpolation/quantization (254 instead of 255) + def assert_red(p): + assert p[0] >= 254 and p[1] < 2 and p[2] < 2 + + assert_red(res.getpixel((0, 0))) + assert_red(res.getpixel((res.width - 1, res.height - 1))) def test_boundary_clamping(): @@ -141,59 +146,61 @@ def test_integration_straighten_modes(): 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 + h_b, w_b = res_b.shape[:2] # --- 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, - # 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 - # 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 - # to that center crop on the ROTATED image. - - # Get rotated size + # Instead of re-deriving the inscribed rect, we simply take the *actual* + # dimensions that Scenario B produced (w_b, h_b) and create a manual crop + # of that exact size, centered on the rotated canvas. + + # NOTE: The editor implementation applies 'crop_box' BEFORE 'straighten_angle' + # (Crop-then-Rotate) if both are present. This makes it impossible to define + # a precise axis-aligned crop on the *rotated* canvas using the standard parameters. + # To simulate a "User cropping the rotated image" correctly in this test, + # we feed the editor a pre-rotated image and set straighten_angle=0. + + # 1) Compute the rotated canvas size using PIL 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) + # Update editor to use the rotated image as 'original' for this scenario + editor.original_image = rot_temp + editor.current_edits["straighten_angle"] = 0.0 + # 2) Create a centered crop rectangle with width=w_b and height=h_b + cx, cy = rw / 2.0, rh / 2.0 + left = cx - w_b / 2.0 + top = cy - h_b / 2.0 + right = left + w_b + bottom = top + h_b + + # 3) Convert to normalized 0-1000 relative to (rw, rh) + # 4) Use round() rather than int() to reduce systematic flooring error + # 5) Clamp to [0, 1000] + def clamp(val): + return max(0, min(1000, val)) + + n_left = clamp(round(left / rw * 1000)) + n_top = clamp(round(top / rh * 1000)) + n_right = clamp(round(right / rw * 1000)) + n_bottom = clamp(round(bottom / rh * 1000)) + + # 6) Set editor.current_edits["crop_box"] editor.current_edits["crop_box"] = (n_left, n_top, n_right, n_bottom) - res_a = editor._apply_edits(img.copy(), for_export=True) + # Use the pre-rotated image + res_a = editor._apply_edits(rot_temp.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 + # Allow for a few pixels difference due to floor/round in rotation math + assert abs(res_a.shape[1] - w_b) < 10 + assert abs(res_a.shape[0] - h_b) < 10 # Verify both are Green (center pixel) - assert res_a.getpixel((res_a.width // 2, res_a.height // 2)) == (0, 255, 0) + # Scale from 0-1 to 0-255 for comparison + pixel = np.round(res_a[res_a.shape[0] // 2, res_a.shape[1] // 2] * 255).astype(int) + assert tuple(pixel) == (0, 255, 0) # ------------------------------------------------------------------------- @@ -250,28 +257,30 @@ def test_rotate_cw(): 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 + h, w = res.shape[:2] # Sample center of quadrants q_w, q_h = w // 4, h // 4 + # Helper to get pixel as 0-255 tuple + def get_p(arr, x, y): + return tuple(np.round(arr[y, x] * 255).astype(int)) + + # Helper for tolerant comparison + def assert_color(c1, c2, msg=""): + assert all(abs(a - b) <= 1 for a, b in zip(c1, c2)), f"{msg}: {c1} != {c2}" + # New TL (Should be Blue) - assert res.getpixel((q_w, q_h)) == (0, 0, 255), "TL should be Blue (was Red)" + assert_color(get_p(res, 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" + assert_color(get_p(res, w - q_w, q_h), (255, 0, 0), "TR should be Red") # New BL (Should be White) - assert res.getpixel((q_w, h - q_h)) == (255, 255, 255), "BL should be White" + assert_color(get_p(res, q_w, h - q_h), (255, 255, 255), "BL should be White") # New BR (Should be Green) - assert res.getpixel((w - q_w, h - q_h)) == (0, 255, 0), "BR should be Green" + assert_color(get_p(res, w - q_w, h - q_h), (0, 255, 0), "BR should be Green") def test_rotate_ccw(): @@ -287,23 +296,31 @@ def test_rotate_ccw(): res = editor._apply_edits(editor.original_image.copy()) - w, h = res.size + h, w = res.shape[:2] q_w, q_h = w // 4, h // 4 + # Helper to get pixel as 0-255 tuple + def get_p(arr, x, y): + return tuple(np.round(arr[y, x] * 255).astype(int)) + # CCW Rotation: # TL (Red) -> BL # TR (Green) -> TL # BL (Blue) -> BR # BR (White) -> TR + # Helper for tolerant comparison + def assert_color(c1, c2, msg=""): + assert all(abs(a - b) <= 1 for a, b in zip(c1, c2)), f"{msg}: {c1} != {c2}" + # New TL (Should be Green) - assert res.getpixel((q_w, q_h)) == (0, 255, 0), "TL should be Green" + assert_color(get_p(res, 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" + assert_color(get_p(res, w - q_w, q_h), (255, 255, 255), "TR should be White") # New BL (Should be Red) - assert res.getpixel((q_w, h - q_h)) == (255, 0, 0), "BL should be Red" + assert_color(get_p(res, q_w, h - q_h), (255, 0, 0), "BL should be Red") # New BR (Should be Blue) - assert res.getpixel((w - q_w, h - q_h)) == (0, 0, 255), "BR should be Blue" + assert_color(get_p(res, w - q_w, h - q_h), (0, 0, 255), "BR should be Blue") diff --git a/faststack/traceback.txt b/faststack/traceback.txt new file mode 100644 index 0000000000000000000000000000000000000000..b12d9982d52b221ebd60fcacd866cf31aa9692e5 GIT binary patch literal 16444 zcmeI3X>T0I5r+G7fc%FA34`m{T=9@(OE4XfGG!BxC5NIQAd<4?Et<>2Jj^gb{(6%4 z>DprV%=D1jl_=|ju^8^`Oiy>+^;Y%pfB#(w<9<5fENq8U{q5*4XQyE=9EHR9`z)-7 z6Wu%2uW?sc2!9TL3ddofxz56l<~|7rn(spE9ESNYr}JrjI^m~qsS$67tIvmCnAKhW z&V(;@|2W<^YNx~3x^q{@-0*5N| z;BhnT>sl}TrhA9_`#L<=wG&Zz9LKyKu5GmSH0qx^;Oj&)9%;;$#-3=6PPlm`o`aG@ zQL!hA`8&{jJPnV>an@gSWm7ZvbR0=ygSZ~g4dQ6-A;Dce)r+4_oHgCu7FC@ny^cnr z=L7M}>Kv~3Q*rC(eT{(=Ry+y)NC)y^CFt!%`W}kX^aK*HF+9sNMz_tit6zQ5g|yLJ zCp->|VI@2X%i%ly%0I7$)P5G;d?X5?Yar^*^*3?Z4;$iQUHop!8ln1Cl;TXZtQlF= zw2n8T_9yZ3Tp!~F`sZ}#Q=}ida{BLvdo3Kyf1(@^1&oJ#!|&Nv9`1gkJS57Umv$Z) zdz+TjmxZ54PbbnH$+Pj@UD-bNzb(sOS0u#mPa2lqkwD8(QyNp0}mdPZSG@ z9r)&f=<{e-^}0NQzG6`3R=x^hQLs5Sqe#khb$OnaA-vWhs zL`HrWTBkkg<}$0FL*avuK;if_dSrG_A+wE-RML}4VlTp~;`?(+BHewTGbrqj_(>=& z&<WZ%19Le}xd-8nE9T@jq_P?}pEX7~tDy^zVtlXD#{%2z2Ml_YZUpKO;YPbiO68 z+SXZ)Bn(SJ7g@R^?=$2<=HAtFKE9dZdvV=~B2AN;>F;_LFY0LBJ^3)SI4(8>h2%j7FNdA3RX@aN9Lbd4|Jd$vD-=oT^4@rNwM5t524t7d-DIt}#mF%cI-)8Ge)% zT@o(sW|SS5j+^2wEBmVcy%M8s9n~bMSZxUkBU(WjbthFavC*#K8H+l);YVFLloy$A zJc>Fwj&bZa>8$kJQhe^Y?4pjLI^mh-8VC!1u4tIko}W7(C5o_>7tw$5{5%NII` z#7-|O!7byC^;A#4)~+&)*Sg*#p%Z{iK&;G0exiebAK+)_SL=db=`+|Uo4v|UI}c0I z-rISgHivF#eHr6a9ou{4BGZ6L>2%=3o>}3uBu~rGr8|>)9?g=u@Md}p$kFn&XF0k` z$GW?zyYx9WH4b^5#b-`Lw^Ig1Sk}%^{_w=S{4K5T(ad6^)wf7I#Tl2Ue?uQuOY~Z~ zqP3?Lwk3(KFu`j0J}l{*dAh^6`&M>mzbzZhDI?#DSIENT=1!Q@@qLs9^ybv&=S~MQ z_Z~}~n&s>setgsOy+$tTYwn~6cs0Ino{2sP@zZ49kNN^dnZ7*xOo+mBt#A-!CaaAGueF6un^aQ7Rwwtb#$RnU-j>*Ucgz0nwyMj{X1y9RMaocoaD!I&-9${GJqOiX?Fm$ zaHcbho8*o~&3vgS`9|l+++$dIg}=0{T#7)@K!jv>#pZr2+6P*jtd-MG#-EN+vc&6k zeU8PU-1nmHu`~8+z>Im;ND&sxvU<}y9JVt=S2kr>YE%5lcG5txpmZ=SsN6JA#&)Zs z<1DUCcY|zQohv;L8u}q-@0aoKyyA3u&UU;Ava9m87gu#1mL=zwmaN6J<92yn&ds%! zpZlDwU(G`{cga3V&rgbMQwt`MJ}^+)w2dYl?)}=3{A@nO-Q` zp;_xYTxwmoz&F71{N*0e)hvg41kb|4xbK#PPdRR1)a$Pb&_U`FnT3rt^ zM}BCR%C?NPCN;()8=RjF=dg3*0|~P$P@S2pkMwz|?|mIab2_JWF5JS?j9yC<(UYI| zWF5pO>azM+tI*oIpsdbZ-NthrS=JrhfqLTjXFBdyqPtUP(TbVC!!z>1lJtG_yUC3D z7yC5typVLUDBdJMb{kRmmZhoQ2XUP7fKOoiw)@|vIj6?rw#lHb$*xu;CNbN-q_khN zf)bZ-$9ovmPt6JjIX~8nW?ic_D=ga>GN|`?@=D0#u;-(0i(T9_kJ||tj9R_K9dU;| zP_LAX20R_>V|UamD3B$6i)5=QGNfy{{Wj-7{)P>!RFx=1{*E1p4|Q*V9&#(WU{{p! z%OYZ1?}Qn@i_zQSDm@uA2(q9Tf=)|Wa6y`ORAkkhWu1e#GPo8h_Vw#nXXF~o@kZa@ z)E9iB>qwlaO?<}kS(RL3Ggr};+cXp}Df_MIyN;K9Ug8avC><{AVGyr8x)7iDb={({ zRiBn0puzJZIcrBBdJ-0ieHXwaG_8?XE{Mq_#9CY7uSMH}>P68@{)IbR`{($CcYu1L z`7->hk#tGO=p9A852GEIOkLv6Z7%tyU9n5nW{)f49*U1L?2$CT6RnAC)3jCZ_vGCn zI)v9taY+)yy3CTy>fEdFCA;#YEaZ8#k+d#Uq{v%Ft?22AD4?1`{-$d%=1J^Zb)>l? z`4(OU8Z#{&rM8G;;= z{1DlK+ORjL+>oVfpo)RpSDm=(#1rqlhAinA`S?3&&cA7h>;rX8DS((juemqE4V^~w zW-^fbxYtnixRvzOj5A1}UH?C5EoihpG&*slMn!Ki$M6qQww&N-FIgaUy~*3~cW#H( zwM(9rq?+&Xc?1K~fKTbJl`JmLpr9K=F66!$^)^zDD#v!))ikM<^5MJXsYlT=QoR8p zzj>#W*q+x2Novl!>%t{(md@;;f2tv?S(?!~8K$uw zJ9@9r#q)`usfg|9w-6^THP^Ym@j86hR`U-2)G*UQGrq`vk43T9(eA5R?V;WgyC+#O z$8{Vr+bDad&9UTa(8QVMN!DZUo}$U-6Qf;$LJrrF;q>cQy=fnIZAh`9544oW(nNdC z57Du$_9FJtdt~p8+;!bsF2pF;mD%~qP@7mpzvGYDeQ+|WnorZV`1pbZE0Jv zExi+AXVu8#8pt_lc7e$FAi;OpyUO*NpZiLalKG10L|*iI2KtSqp)q@Fn_4(&$zoP% zCbFBM3Uqxv2Y%P~j_R}fJ`NG;-S)s%WgSj4l{$5LSE$Ev4Y`%7#&VFMQusVq1hS|K zj&=JZR$3>6F*4SVed0yl?e-!@IDczJIvZx zp7q4o1I%mx`S=cNNmHXzlh2W#yA9or=1r#gdilApjdT|?yym=>dzOs0ofY1D@LGqR zIxN@nF4YgVgj7!z-P=}OjUCKI&9f{mB6;*;@ymNf*dXs0p0(8H&{LhqxMn6hOMR!V zMtWEAPPFlMd$TU4Re(q&;XQnV*-NWpRTn}6R08aXlL6_ad9COaamC z8INDPp3EcK_id`<9KjeW8dSWex*K~lHOT&v_(V$d!oa)qtJoJQQP22ivY9I4TZ>gc zx`9$W*Y;1I>NmN0O?O6_jXrfZd*7ltZ&{L^ z&n=*in%vg%S@LtI`gV{?o&M6wzi;mQBl^zC=B!pPN8j=O>xDXjck1mwBRo4;WkGL` zjv%`lAr0sW0lt Date: Sat, 7 Feb 2026 12:57:21 -0800 Subject: [PATCH 4/5] formatted with black --- faststack/app.py | 19 ++++++++--------- faststack/imaging/cache.py | 10 +++++---- faststack/imaging/editor.py | 8 ++++++-- faststack/imaging/prefetch.py | 18 ++++++++-------- faststack/io/indexer.py | 6 +++--- faststack/repro_type_error.py | 7 +++++-- faststack/tests/test_editor_integration.py | 12 ++++------- faststack/tests/test_editor_rotation.py | 2 +- faststack/tests/test_highlight_recovery.py | 24 +++++++++++----------- 9 files changed, 55 insertions(+), 51 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index d0e20ad..b105e3b 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -161,9 +161,9 @@ def __init__( self.image_dir = image_dir self.image_files: List[ImageFile] = [] # Filtered list for display self._all_images: List[ImageFile] = [] # Cached full list from disk - self._path_to_index: Dict[ - Path, int - ] = {} # Resolved path -> 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 @@ -223,9 +223,9 @@ def __init__( # -- 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._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, @@ -295,9 +295,9 @@ def __init__( 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)), ...] + self.delete_history: List[DeleteRecord] = ( + [] + ) # [((jpg_src, jpg_bin), (raw_src, raw_bin)), ...] # Track all undoable actions with timestamps # [(action_type, action_data, timestamp)] @@ -5493,4 +5493,3 @@ def cli(): if __name__ == "__main__": cli() - diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py index c0eb764..dde13ee 100644 --- a/faststack/imaging/cache.py +++ b/faststack/imaging/cache.py @@ -68,7 +68,7 @@ def clear(self): def pop_path(self, path: Union[Path, str]): """Targeted invalidation of all generations for a given path. - + Hardened to handle both Path objects and string keys, and resolved paths. Expected type: Union[Path, str]. """ @@ -91,12 +91,14 @@ def pop_path(self, path: Union[Path, str]): if key_str == t_str or key_str.startswith(f"{t_str}::"): keys_to_remove.append(key) break - + for k in keys_to_remove: self.pop(k, None) - + if keys_to_remove: - log.debug(f"Invalidated {len(keys_to_remove)} cache entries for path: {path}") + log.debug( + f"Invalidated {len(keys_to_remove)} cache entries for path: {path}" + ) def get_decoded_image_size(item) -> int: diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index 1e9820c..f10cf6d 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -280,7 +280,11 @@ def rotate_autocrop_rgb( is_exact_90 = abs(angle_deg % 90.0) < 0.01 actual_inset = 0 if is_exact_90 else inset - if actual_inset > 0 and (right - left) > 2 * actual_inset and (bottom - top) > 2 * actual_inset: + if ( + actual_inset > 0 + and (right - left) > 2 * actual_inset + and (bottom - top) > 2 * actual_inset + ): left += actual_inset top += actual_inset right -= actual_inset @@ -710,7 +714,7 @@ def _apply_edits( # Skip for exact 90-degree increments to preserve full dimensions. is_exact_90 = abs(straighten_angle % 90.0) < 0.01 inset = 0 if is_exact_90 else 2 - + if (right - left) > 2 * inset and (bottom - top) > 2 * inset: left += inset top += inset diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index 5383ee8..c06c857 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -828,15 +828,15 @@ def _decode_and_cache( # 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() diff --git a/faststack/io/indexer.py b/faststack/io/indexer.py index af7dcd8..27737a8 100644 --- a/faststack/io/indexer.py +++ b/faststack/io/indexer.py @@ -45,9 +45,9 @@ def find_images(directory: Path) -> List[ImageFile]: # 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() diff --git a/faststack/repro_type_error.py b/faststack/repro_type_error.py index 63e107d..8bca170 100644 --- a/faststack/repro_type_error.py +++ b/faststack/repro_type_error.py @@ -1,6 +1,6 @@ - import sys from pathlib import Path + # Ensure we can import faststack sys.path.insert(0, r"C:\code\faststack") @@ -17,10 +17,13 @@ res = editor._apply_edits(img) print(f"Result type: {type(res)}") if res is not None: - print(f"Result shape/size: {getattr(res, 'shape', 'N/A')} / {getattr(res, 'size', 'N/A')}") + print( + f"Result shape/size: {getattr(res, 'shape', 'N/A')} / {getattr(res, 'size', 'N/A')}" + ) else: print("Result is None!") except Exception as e: print(f"Caught exception: {type(e).__name__}: {e}") import traceback + traceback.print_exc() diff --git a/faststack/tests/test_editor_integration.py b/faststack/tests/test_editor_integration.py index b0f04ff..64927de 100644 --- a/faststack/tests/test_editor_integration.py +++ b/faststack/tests/test_editor_integration.py @@ -36,12 +36,12 @@ def setUp(self): self.controller.image_editor.current_filepath = Path("test.jpg") self.controller.image_editor.float_image = MagicMock() self.controller.image_editor.original_image = MagicMock() - + # Initialize state for delegation tests self.controller.image_files = [MagicMock(path=Path("test.jpg"))] self.controller.current_index = 0 self.controller.auto_level_threshold = 0.001 - + # Mock returns for methods that unpack results self.controller.image_editor.auto_levels.return_value = (0, 255, 0, 255) self.controller.image_editor.save_image.return_value = (Path("test.jpg"), None) @@ -66,9 +66,7 @@ def test_missing_methods(self): try: self.controller.rotate_image_cw() # AppController delegates rotation via set_edit_param("rotation", ...) - self.controller.image_editor.set_edit_param.assert_any_call( - "rotation", 270 - ) + self.controller.image_editor.set_edit_param.assert_any_call("rotation", 270) except AttributeError: self.fail("AppController is missing method 'rotate_image_cw'") @@ -76,9 +74,7 @@ def test_missing_methods(self): try: self.controller.rotate_image_ccw() # AppController delegates rotation via set_edit_param("rotation", ...) - self.controller.image_editor.set_edit_param.assert_any_call( - "rotation", 90 - ) + self.controller.image_editor.set_edit_param.assert_any_call("rotation", 90) except AttributeError: self.fail("AppController is missing method 'rotate_image_ccw'") diff --git a/faststack/tests/test_editor_rotation.py b/faststack/tests/test_editor_rotation.py index 118b114..3756778 100644 --- a/faststack/tests/test_editor_rotation.py +++ b/faststack/tests/test_editor_rotation.py @@ -160,7 +160,7 @@ def test_integration_straighten_modes(): # a precise axis-aligned crop on the *rotated* canvas using the standard parameters. # To simulate a "User cropping the rotated image" correctly in this test, # we feed the editor a pre-rotated image and set straighten_angle=0. - + # 1) Compute the rotated canvas size using PIL rot_temp = img.rotate(-angle, expand=True) rw, rh = rot_temp.size diff --git a/faststack/tests/test_highlight_recovery.py b/faststack/tests/test_highlight_recovery.py index b124e07..f01a548 100644 --- a/faststack/tests/test_highlight_recovery.py +++ b/faststack/tests/test_highlight_recovery.py @@ -159,16 +159,16 @@ 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") @@ -233,9 +233,9 @@ def test_pivot_behavior(): low_values.copy(), amount=amount, pivot=0.5 ) diff = np.abs(recovered - low_values).max() - assert diff < 1e-5, ( - f"Values below pivot changed with amount={amount}: max_diff={diff}" - ) + assert ( + diff < 1e-5 + ), f"Values below pivot changed with amount={amount}: max_diff={diff}" print("test_pivot_behavior passed") @@ -253,9 +253,9 @@ def test_increasing_amount_increases_compression(): avg_high = recovered_high.mean() # avg_high should be lower (more compressed toward 1.0) than avg_low - assert avg_high <= avg_low, ( - f"Higher amount should compress more: avg_low={avg_low:.4f}, avg_high={avg_high:.4f}" - ) + assert ( + avg_high <= avg_low + ), f"Higher amount should compress more: avg_low={avg_low:.4f}, avg_high={avg_high:.4f}" print("test_increasing_amount_increases_compression passed") From e7351ca5159691611a81e5aa8108ef38b2dbdecd Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 7 Feb 2026 15:09:28 -0800 Subject: [PATCH 5/5] minor changes suggested by Coderabbit --- ChangeLog.md | 27 ++++++++++++++++++++ faststack/app.py | 6 +++-- faststack/repro_type_error.py | 5 ++-- faststack/tests/test_deletion_unification.py | 1 + faststack/tests/test_editor_integration.py | 18 +++++++++++++ pyproject.toml | 2 +- 6 files changed, 54 insertions(+), 5 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 0e37789..07e8d4c 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,33 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. +# Changelog + +## 1.5.5 (2026-02-07) + +### Changed +- Image save behavior in the editor is now navigation-aware: + - Only clear editor state / close editor UI when the user is still on the same image. + - Only perform a full list refresh + re-select logic when the user is still on the same image. + - If the user navigated away, preserve their selection and only invalidate the saved image’s cache entry. + +- Recycle/delete of JPG+RAW pairs is now more atomic and robust: + - Check RAW existence **before** any moves to avoid post-move existence ambiguity. + - Move JPG first; only attempt RAW move if JPG succeeds and RAW existed. + - If RAW move fails after JPG succeeds, roll back the JPG move to keep pairs consistent. + - Track `raw_moved` based on whether RAW existed and whether it was moved successfully. + +- Cache invalidation after edits is now targeted instead of global: + - Replace multiple `image_cache.clear()` calls after save/export with `image_cache.pop_path(saved_path)` to invalidate only the edited file. + +- Keep internal path→index lookup consistent: + - Rebuild the path-to-index map after operations that mutate the image list, including after recycle/rollback flows. + +### Fixed +- Rotation/autocrop and straighten edge handling: + - Use `floor()` instead of `round()` in inscribed-rectangle and crop coordinate math to reduce off-by-one drift. + - Skip inset trimming for exact 90° rotations to preserve full dimensions and avoid unnecessary cropping. + ## 1.5.4 (2026-02-04) ### Fixed diff --git a/faststack/app.py b/faststack/app.py index b105e3b..13e9e8d 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -2905,7 +2905,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: log.info(f"Restored {jpg_path.name} from recycle bin") undo_succeeded = True except (OSError, shutil.Error) as undo_err: - log.error( + log.exception( f"Failed to undo JPG move for {jpg_path.name}: {undo_err}" ) # Mark as deleted to prevent rollback from resurrecting missing image @@ -2992,7 +2992,9 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: log.info( f"Rolling back {len(items_to_restore)} items after incomplete deletion" ) - # Restore items in ascending index order + # Restore items in descending index order + # Restore in descending order to preserve index validity + items_to_restore.sort(key=lambda x: x[0], reverse=True) for idx, img in items_to_restore: # Clamp insertion index to valid range insert_idx = min(idx, len(self.image_files)) diff --git a/faststack/repro_type_error.py b/faststack/repro_type_error.py index 8bca170..a536809 100644 --- a/faststack/repro_type_error.py +++ b/faststack/repro_type_error.py @@ -2,7 +2,8 @@ from pathlib import Path # Ensure we can import faststack -sys.path.insert(0, r"C:\code\faststack") +repo_root = str(Path(__file__).resolve().parent.parent) +sys.path.insert(0, repo_root) from faststack.imaging.editor import ImageEditor from PIL import Image @@ -22,7 +23,7 @@ ) else: print("Result is None!") -except Exception as e: +except Exception as e: # noqa: BLE001 print(f"Caught exception: {type(e).__name__}: {e}") import traceback diff --git a/faststack/tests/test_deletion_unification.py b/faststack/tests/test_deletion_unification.py index d8301e6..1094fa2 100644 --- a/faststack/tests/test_deletion_unification.py +++ b/faststack/tests/test_deletion_unification.py @@ -19,6 +19,7 @@ def qapp(): @pytest.fixture def mock_controller(tmp_path, qapp): """Creates an AppController with mocked dependencies.""" + _ = qapp # Keep QApplication active for UI-touching code engine = Mock() with ( patch("faststack.app.Watcher"), diff --git a/faststack/tests/test_editor_integration.py b/faststack/tests/test_editor_integration.py index 64927de..ebba82d 100644 --- a/faststack/tests/test_editor_integration.py +++ b/faststack/tests/test_editor_integration.py @@ -46,6 +46,24 @@ def setUp(self): self.controller.image_editor.auto_levels.return_value = (0, 255, 0, 255) self.controller.image_editor.save_image.return_value = (Path("test.jpg"), None) + # Mock _save_executor to be synchronous to avoid race conditions + self.controller._save_executor = MagicMock() + + def mock_submit(fn, *args, **kwargs): + # Execute synchronously + result = fn(*args, **kwargs) + # Return a mock future that triggers callbacks immediately + mock_future = MagicMock() + mock_future.result.return_value = result + + def mock_add_done_callback(callback): + callback(mock_future) + + mock_future.add_done_callback.side_effect = mock_add_done_callback + return mock_future + + self.controller._save_executor.submit.side_effect = mock_submit + def tearDown(self): self.config_patcher.stop() diff --git a/pyproject.toml b/pyproject.toml index 02a3a6f..b08fd09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.5.4" +version = "1.5.5" authors = [ { name="Alan Rockefeller"}, ]