diff --git a/ChangeLog.md b/ChangeLog.md index a30cf56..c36f9cb 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,6 +2,19 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. +## 1.6.0 (2026-03-06) + +- Added a "Todo" flag: toggle with D, filterable in Filter dialog, shown on thumbnails (badge, tile visuals, red on sparkline), and displayed as "Todo since {date}" in the UI. +- Fixed batch range alignment after deletions to prevent stale/misaligned UI state. +- Improved cross‑platform thumbnail path matching, more robust shutdown/teardown, and reduced eviction-related UI races. +- Fixed image list refresh after filesystem watcher events so the current image stays selected when possible. +- Fixed undo and rollback so deleted images are restored to the correct positions. +- Fixed stack state being lost or shifted incorrectly after delete and undo operations. +- Improved display cache invalidation so zoom, resize, filter, and edit changes refresh the correct image version. +- Improved prefetch behavior when zooming or resizing to reduce stale background work. +- Improved thumbnail lookup speed by adding a faster path-to-row mapping. +- Reduced chances of UI state getting out of sync after external file changes. + ## 1.5.9 (2026-02-16) - Full-Screen Mode: Press F11 to toggle fullscreen in loupe view diff --git a/README.md b/README.md index cb1383d..a2cc359 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 1.5.9 - February 16, 2026 +# Version 1.6.0 - March 6, 2026 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. @@ -76,7 +76,8 @@ faststack - `S`: Toggle current image in/out of stack - `X`: Remove current image from batch/stack - `B`: Toggle current image in/out of batch -- `[`: Begin new stack group +- `D`: Toggle todo flag - shows up red on the sparkline so you can see if you have flagged images to work on later +- `[`: Begin new stack group - `]`: End current stack group - `C`: Clear all stacks - `{`: Begin new drag & drop batch diff --git a/faststack/app.py b/faststack/app.py index 5436eb6..d18f96a 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -264,6 +264,7 @@ def __init__( self.display_width = 0 self.display_height = 0 self.display_generation = 0 + self._display_lock = threading.Lock() self._is_decoding = False # Cache Warning State @@ -436,7 +437,7 @@ def __init__( self._watcher_debounce_timer = QTimer(self) self._watcher_debounce_timer.setSingleShot(True) self._watcher_debounce_timer.setInterval(200) # 200ms debounce - self._watcher_debounce_timer.timeout.connect(self.refresh_image_list) + self._watcher_debounce_timer.timeout.connect(self._on_watcher_refresh) # Periodic summary for thumbnail debug logging if self.debug_thumb_timing or self.debug_thumb_trace: @@ -569,9 +570,7 @@ def apply_filter(self, filter_string: str, filter_flags: list): self._filter_flags = flags self._filter_enabled = True self._apply_filter_to_cached_list() # Fast in-memory filtering - self.display_generation += ( - 1 # Invalidate cache keys to prevent showing stale images - ) + self._bump_display_generation() self.dataChanged.emit() self.ui_state.filterStringChanged.emit() # Notify UI of filter change @@ -619,9 +618,7 @@ def clear_filter(self): self._filter_string = "" self._filter_flags = [] self._apply_filter_to_cached_list() # Fast in-memory filtering - self.display_generation += ( - 1 # Invalidate cache keys to prevent showing stale images - ) + self._bump_display_generation() self.dataChanged.emit() self.ui_state.filterStringChanged.emit() # Notify UI of filter change @@ -647,10 +644,15 @@ def clear_filter(self): self._do_prefetch(self.current_index) def get_display_info(self): - if self.is_zoomed: - return 0, 0, self.display_generation - - return self.display_width, self.display_height, self.display_generation + with self._display_lock: + if self.is_zoomed: + return 0, 0, self.display_generation + return self.display_width, self.display_height, self.display_generation + + def _bump_display_generation(self): + """Increment display_generation under _display_lock.""" + with self._display_lock: + self.display_generation += 1 def on_display_size_changed(self, width: int, height: int): """Debounces display size change events to prevent spamming resizes.""" @@ -673,9 +675,10 @@ def _handle_resize(self): self.pending_width, self.pending_height, ) - self.display_width = self.pending_width - self.display_height = self.pending_height - self.display_generation += 1 # Invalidates old entries via cache key + with self._display_lock: + self.display_width = self.pending_width + self.display_height = self.pending_height + self.display_generation += 1 # Invalidates old entries via cache key # Mark display as ready after first size report is_first_resize = not self.display_ready @@ -697,17 +700,24 @@ def _handle_resize(self): @Slot(bool) def set_zoomed(self, zoomed: bool): - if self.is_zoomed != zoomed: + with self._display_lock: + if self.is_zoomed == zoomed: + return # No-op: avoid unnecessary cache invalidation if _debug_mode: log.info(f"AppController.set_zoomed: {self.is_zoomed} -> {zoomed}") self.is_zoomed = zoomed - self.is_zoomed_changed.emit(zoomed) + self.display_generation += 1 # Invalidates old entries via cache key + self.is_zoomed_changed.emit(zoomed) log.info("Zoom state changed to: %s", zoomed) - self.display_generation += 1 # Invalidates old entries via cache key - # Invalidate current image to force reload with new resolution logic + # Cancel stale prefetch tasks and restart at the new resolution, + # mirroring what _handle_resize() does. + self.prefetcher.cancel_all() + if self._loupe_decode_allowed(): + self.prefetcher.update_prefetch(self.current_index) + + # Force QML to reload the image at the new resolution if self.image_files and self.main_window: - # Force QML to reload the image by updating the URL generation self.ui_refresh_generation += 1 self.ui_state.currentImageSourceChanged.emit() self.main_window.update() # Force repaint @@ -960,6 +970,46 @@ def refresh_image_list(self): self._path_resolver.update_from_model(self._thumbnail_model) self._grid_model_dirty = False + @Slot() + def _on_watcher_refresh(self): + """Watcher debounce handler: rescan disk and keep UI consistent. + + Unlike bare refresh_image_list(), this clamps current_index, + updates the prefetcher, and syncs the UI so the display never + references an out-of-bounds index. + """ + # Remember which image we were viewing so we can stay on it + preserved_path = None + if self.image_files and 0 <= self.current_index < len(self.image_files): + preserved_path = self.image_files[self.current_index].path + + self.refresh_image_list() + + # Bust decode cache so modified-on-disk files are re-decoded + self._bump_display_generation() + + # Re-select the same image if it still exists, otherwise clamp + if self.image_files and preserved_path: + target_key = self._key(preserved_path) + new_idx = self._path_to_index.get(target_key) + if new_idx is not None: + self.current_index = new_idx + else: + # Image gone — clear stale variant override + self._clear_variant_override() + self.current_index = min( + self.current_index, len(self.image_files) - 1 + ) + else: + # List empty or no preserved path — clear stale variant override + self._clear_variant_override() + self.current_index = max(0, min( + self.current_index, len(self.image_files) - 1 + )) if self.image_files else 0 + + self._do_prefetch(self.current_index) + self.sync_ui_state() + def _apply_filter_to_cached_list(self): """Applies current filter to cached image list without disk I/O.""" if self._filter_enabled and self._filter_string: @@ -3201,7 +3251,7 @@ def set_color_mode(self, mode: str): # Clear cache and restart prefetcher to apply new color mode self.image_cache.clear() self.prefetcher.cancel_all() - self.display_generation += 1 + self._bump_display_generation() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() @@ -3233,7 +3283,7 @@ def set_saturation_factor(self, factor: float): if self.get_color_mode() == "saturation": self.image_cache.clear() self.prefetcher.cancel_all() - self.display_generation += 1 + self._bump_display_generation() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() @@ -3262,7 +3312,7 @@ def set_awb_strength(self, value): if self.get_color_mode() in ["saturation", "icc"]: self.image_cache.clear() self.prefetcher.cancel_all() - self.display_generation += 1 + self._bump_display_generation() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() @@ -3527,7 +3577,7 @@ def _switch_to_directory( self.last_displayed_image = None self.image_cache.clear() self.prefetcher.cancel_all() - self.display_generation += 1 + self._bump_display_generation() self._metadata_cache = {} self._metadata_cache_index = (-1, -1) self._exif_brief_cache.clear() @@ -4230,16 +4280,27 @@ def _shift(orig_idx: int) -> int: new_batches.append([ns, ne]) return new_batches + @staticmethod + def _shift_start_index( + saved_start: Optional[int], still_deleted: List[int] + ) -> Optional[int]: + """Shift a saved start index past still-deleted positions.""" + if saved_start is None: + return None + if saved_start in still_deleted: + return None + return saved_start - sum(1 for d in still_deleted if d < saved_start) + def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> None: """Restore items to the UI list in correct order.""" - # Sort reverse by index to insert correctly - # Access attributes of DeleteJob - for idx, img in sorted(items, key=lambda x: x[0], reverse=True): + # Insert in ascending index order so each insertion shifts subsequent + # indices correctly, restoring the original list positions. + for idx, img in sorted(items, key=lambda x: x[0]): self.image_files.insert(min(idx, len(self.image_files)), img) # Restore selection/focus (approximated) self.current_index = min(job.previous_index, len(self.image_files) - 1) - self.display_generation += 1 + self._bump_display_generation() # Targeted cache invalidation instead of full clear if self.image_cache is not None: @@ -4258,7 +4319,7 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No self.prefetcher.update_prefetch(self.current_index) # Restore saved batch state if present - if job.saved_batches and items: + if job.saved_batches is not None and items: original = {idx for idx, _ in job.removed_items} restored = {idx for idx, _ in items} if restored == original: @@ -4271,8 +4332,29 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No self.batches = self._recompute_batches_after_deletions( job.saved_batches, still_deleted ) + self.batch_start_index = self._shift_start_index( + job.saved_batch_start_index, still_deleted + ) self._invalidate_batch_cache() + # Restore saved stack state if present + if job.saved_stacks is not None and items: + original = {idx for idx, _ in job.removed_items} + restored = {idx for idx, _ in items} + if restored == original: + self.stacks = [s[:] for s in job.saved_stacks] + self.stack_start_index = job.saved_stack_start_index + else: + still_deleted = sorted(original - restored) + self.stacks = self._recompute_batches_after_deletions( + job.saved_stacks, still_deleted + ) + self.stack_start_index = self._shift_start_index( + job.saved_stack_start_index, still_deleted + ) + self.sidecar.data.stacks = self.stacks + self._metadata_cache_index = (-1, -1) + def _schedule_delete_refresh(self) -> None: """Debounce post-delete refresh: coalesce rapid deletes into one refresh.""" if self._refresh_scheduled: @@ -4383,21 +4465,22 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: shift = sum(1 for d in validated_sorted if d < previous_index) self.current_index = max(0, min(previous_index - shift, len(self.image_files) - 1)) - # Save batch state before mutation so _rollback_ui_items can restore it - # for any delete type (loupe, grid, batch). batch delete_batch_images() - # will overwrite saved_batches with the same pre-mutation value anyway. - pre_batch_snapshot = [b[:] for b in self.batches] if self.batches else None - pre_batch_start_snapshot = self.batch_start_index if self.batches else None + # Save batch/stack state before mutation so rollback can restore it. + pre_batch_snapshot = [b[:] for b in self.batches] + pre_batch_start_snapshot = self.batch_start_index + pre_stack_snapshot = [s[:] for s in self.stacks] + pre_stack_start_snapshot = self.stack_start_index + + # Shared helper: compute post-deletion index for a surviving index. + deleted_ascending = sorted(validated_sorted) + + def _shift(orig_idx: int) -> int: + return orig_idx - sum(1 for d in deleted_ascending if d < orig_idx) # Adjust batch index ranges to account for removed entries. # Deleting index d shifts every index > d down by one. Without this, # batches that sit above any deleted image reference the wrong files. if self.batches: - deleted_ascending = sorted(validated_sorted) - - def _shift(orig_idx: int) -> int: - return orig_idx - sum(1 for d in deleted_ascending if d < orig_idx) - new_batches = [] for b_start, b_end in self.batches: # Anchor on the first and last *surviving* indices in the range. @@ -4431,6 +4514,36 @@ def _shift(orig_idx: int) -> int: self.batch_start_index = shifted self._invalidate_batch_cache() + # Adjust stack index ranges (same algorithm as batches). + if self.stacks: + new_stacks = [] + for s_start, s_end in self.stacks: + first_ok = next( + (i for i in range(s_start, s_end + 1) if i not in deleted_set), + None, + ) + if first_ok is None: + continue # whole stack deleted — drop it + last_ok = next( + (i for i in range(s_end, s_start - 1, -1) if i not in deleted_set), + None, + ) + ns = _shift(first_ok) + ne = _shift(last_ok) + if ns <= ne: + new_stacks.append([ns, ne]) + if new_stacks != self.stacks: + self.stacks = new_stacks + self.sidecar.data.stacks = self.stacks + self._metadata_cache_index = (-1, -1) + + # Adjust stack_start_index for removed entries + if pre_stack_start_snapshot is not None: + if pre_stack_start_snapshot in deleted_set: + self.stack_start_index = None + else: + self.stack_start_index = _shift(pre_stack_start_snapshot) + # Update UI immediately - this is fast since it just reads from memory # Check for existence, not truthiness (empty cache is falsy) if self.image_cache is not None: @@ -4509,6 +4622,8 @@ def _shift(orig_idx: int) -> int: images_to_delete=images_to_delete, saved_batches=pre_batch_snapshot, saved_batch_start_index=pre_batch_start_snapshot, + saved_stacks=pre_stack_snapshot, + saved_stack_start_index=pre_stack_start_snapshot, ) # Add single placeholder undo entry per job @@ -4749,7 +4864,7 @@ def _post_undo_refresh_and_select( except OSError: continue - self.display_generation += 1 + self._bump_display_generation() self.image_cache.clear() self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) @@ -4784,13 +4899,14 @@ def undo_delete(self): removed_items = job.removed_items previous_index = job.previous_index - # Re-insert in descending order to preserve correct indices - for idx, img in sorted(removed_items, key=lambda x: x[0], reverse=True): + # Re-insert in ascending index order so each insertion shifts + # subsequent indices correctly, restoring original positions. + for idx, img in sorted(removed_items, key=lambda x: x[0]): insert_idx = min(idx, len(self.image_files)) self.image_files.insert(insert_idx, img) self.current_index = min(previous_index, len(self.image_files) - 1) - self.display_generation += 1 + self._bump_display_generation() # Targeted eviction instead of full clear if self.image_cache is not None: paths_to_evict = [] @@ -4804,10 +4920,16 @@ def undo_delete(self): self.prefetcher.update_prefetch(self.current_index) self._rebuild_path_to_index() # Restore batch state that was shifted during _delete_indices - if job.saved_batches and removed_items: + if job.saved_batches is not None and removed_items: self.batches = job.saved_batches self.batch_start_index = job.saved_batch_start_index self._invalidate_batch_cache() + # Restore stack state that was shifted during _delete_indices + if job.saved_stacks is not None and removed_items: + self.stacks = job.saved_stacks + self.stack_start_index = job.saved_stack_start_index + self.sidecar.data.stacks = self.stacks + self._metadata_cache_index = (-1, -1) self.sync_ui_state() count = len(removed_items) @@ -6462,7 +6584,7 @@ def execute_crop(self): break # Invalidate cache and refresh display - self.display_generation += 1 + self._bump_display_generation() self.image_cache.pop_path(saved_path) self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) @@ -6697,7 +6819,7 @@ def quick_auto_levels(self): self._reindex_after_save(saved_path) t_list = time.perf_counter() - self.display_generation += 1 + self._bump_display_generation() self.image_cache.pop_path(saved_path) self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) @@ -6846,7 +6968,7 @@ def _batch_auto_levels_done(self): elapsed_ms = int((time.perf_counter() - self._batch_al_t_start) * 1000) # Refresh display - self.display_generation += 1 + self._bump_display_generation() self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self._metadata_cache_index = (-1, -1) @@ -6938,7 +7060,7 @@ def quick_auto_white_balance(self): t_list = time.perf_counter() # Invalidate cache for the edited image so it's reloaded from disk - self.display_generation += 1 + self._bump_display_generation() self.image_cache.pop_path(saved_path) self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) diff --git a/faststack/deletion_types.py b/faststack/deletion_types.py index 67940a5..d6ec1e5 100644 --- a/faststack/deletion_types.py +++ b/faststack/deletion_types.py @@ -46,6 +46,8 @@ class DeleteJob: undo_requested: bool = False # Policy 1: auto-restore files on completion saved_batches: Optional[list] = None saved_batch_start_index: Optional[int] = None + saved_stacks: Optional[list] = None + saved_stack_start_index: Optional[int] = None @dataclass diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index bde10a3..8db3f1f 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -262,13 +262,20 @@ def update_prefetch( effective_radius, ) - # Calculate asymmetric range based on direction + # Calculate asymmetric range based on direction, ensuring at least + # 1 slot per side even when effective_radius is very small. if self._last_navigation_direction > 0: # Moving forward behind = max(1, int(effective_radius * (1 - self._direction_bias))) - ahead = effective_radius - behind + 1 + ahead = effective_radius - behind + if ahead < 1: + ahead = 1 + behind = effective_radius - ahead else: # Moving backward ahead = max(1, int(effective_radius * (1 - self._direction_bias))) - behind = effective_radius - ahead + 1 + behind = effective_radius - ahead + if behind < 1: + behind = 1 + ahead = effective_radius - behind # Invariant: All reads/writes of self.futures, self._scheduled, self.generation, # and self.image_files that participate in scheduling or cancellation MUST diff --git a/faststack/thumbnail_view/model.py b/faststack/thumbnail_view/model.py index 3ce0fa9..fd08620 100644 --- a/faststack/thumbnail_view/model.py +++ b/faststack/thumbnail_view/model.py @@ -20,7 +20,7 @@ ) from faststack.models import ImageFile -from faststack.io.utils import compute_path_hash +from faststack.io.utils import compute_path_hash, normalize_path_key from faststack.io.indexer import find_images from faststack.thumbnail_view.folder_stats import ( FolderStats, @@ -154,6 +154,8 @@ def __init__( # Mapping from thumbnail_id (without query params) to row index # id format: "{size}/{path_hash}/{mtime_ns}" self._id_to_row: Dict[str, int] = {} + # Normalized-path → row index for O(1) find_image_index() lookups + self._path_to_row: Dict[str, int] = {} # Connect our own signal to handle thumbnail ready events self.thumbnailReady.connect(self._on_thumbnail_ready) @@ -383,6 +385,7 @@ def refresh(self): try: self._entries.clear() self._id_to_row.clear() + self._path_to_row.clear() self._selected_indices.clear() self._last_selected_index = None @@ -527,6 +530,7 @@ def refresh_from_controller( try: self._entries.clear() self._id_to_row.clear() + self._path_to_row.clear() self._selected_indices.clear() self._last_selected_index = None @@ -655,6 +659,7 @@ def _add_images_to_entries( def _rebuild_id_mapping(self): """Rebuilds the path/stack_id -> row mapping.""" self._id_to_row.clear() + self._path_to_row.clear() # Key must match the thumbnail_id format emitted by the prefetcher: # "{size}/{path_hash}/{mtime_ns}" — same as _make_thumbnail_id() @@ -662,6 +667,8 @@ def _rebuild_id_mapping(self): if not e.is_folder: tid = self._make_thumbnail_id(e) self._id_to_row[tid] = i + # Normalized key for O(1) find_image_index() lookups + self._path_to_row[normalize_path_key(e.path)] = i def _make_thumbnail_id(self, entry: ThumbnailEntry) -> str: """Create thumbnail ID without query params.""" @@ -850,9 +857,7 @@ def find_image_index(self, path: Path) -> int: """Find the row index of an image by path. Returns -1 if not found. + Uses O(1) dict lookup instead of O(n) linear scan with .resolve(). """ - resolved = path.resolve() - for i, entry in enumerate(self._entries): - if not entry.is_folder and entry.path.resolve() == resolved: - return i - return -1 + key = normalize_path_key(path) + return self._path_to_row.get(key, -1) diff --git a/pyproject.toml b/pyproject.toml index 2162bce..b5ba5a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.5.9" +version = "1.6.0" authors = [ { name="Alan Rockefeller"}, ]