From 6182ba6526a8bc3b159849d3ab957969fa97cce6 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 17 May 2026 17:04:16 -0700 Subject: [PATCH 01/13] Fix broken menu --- faststack/qml/Main.qml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 1e5d393..903503d 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -892,11 +892,11 @@ ApplicationWindow { } onHoveredChanged: { if (hovered) { - sortSubMenu.popup(sortPhotosLauncher, sortPhotosLauncher.width - 4, 0) + sortSubMenu.popupAt(sortPhotosLauncher, sortPhotosLauncher.width - 4, 0) } } onClicked: { - sortSubMenu.popup(sortPhotosLauncher, sortPhotosLauncher.width - 4, 0) + sortSubMenu.popupAt(sortPhotosLauncher, sortPhotosLauncher.width - 4, 0) } // Ensure keyboard activation works reliably Keys.onReturnPressed: clicked() @@ -999,10 +999,22 @@ ApplicationWindow { } } - Menu { + // A Popup, not a Menu: opening a sibling Menu would auto-close + // actionsMenu (Qt menu mutual-exclusion). Popups don't exclude. + Popup { id: sortSubMenu parent: Overlay.overlay implicitWidth: 180 + padding: 0 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + // Position relative to a source item, mapped into the overlay. + function popupAt(item, dx, dy) { + var p = item.mapToItem(Overlay.overlay, dx, dy) + x = p.x + y = p.y + open() + } background: Rectangle { implicitWidth: 180 From cbd4aa6426fbd2f5094ff0c2bf0338d2cad3c26b Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 17 May 2026 17:04:16 -0700 Subject: [PATCH 02/13] Fix crop preview bug --- faststack/app.py | 81 ++++++++++++++++++++++++++++++++----- faststack/imaging/editor.py | 55 ++++++++++++++++++------- 2 files changed, 110 insertions(+), 26 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 029d5c9..c8b37f7 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1995,6 +1995,49 @@ def _current_live_session_has_meaningful_edits(self) -> bool: return False + def _current_live_session_has_geometry_edits(self) -> bool: + """Return True when live edits change image dimensions/orientation.""" + if self.image_editor is None: + return False + if ( + self.image_editor.current_filepath is None + or self.image_editor.original_image is None + ): + return False + + edits = getattr(self.image_editor, "current_edits", None) or {} + + try: + if int(edits.get("rotation", 0)) % 360 != 0: + return True + except (TypeError, ValueError): + return True + + try: + if abs(float(edits.get("straighten_angle", 0.0))) > 0.001: + return True + except (TypeError, ValueError): + return True + + crop_box = edits.get("crop_box") + if crop_box is not None: + try: + normalized_crop_box = tuple(int(v) for v in crop_box) + except (TypeError, ValueError): + return True + if normalized_crop_box != (0, 0, 1000, 1000): + return True + + return False + + def _should_render_live_preview_full_resolution(self) -> bool: + """Use full-res live previews after committed geometry edits.""" + if self.ui_state is None: + return False + if self.ui_state.isEditorOpen or self.ui_state.isCropping: + return False + return self._current_live_session_has_geometry_edits() + def _is_current_live_edit_session_dirty(self) -> bool: """Return True when the current session has unsaved edits beyond the latest submitted save.""" state = self._live_edit_session_state @@ -8231,11 +8274,17 @@ def _apply_histogram_result(self, payload): if pending: self.histogram_timer.start() - def _kick_preview_worker(self): + def _kick_preview_worker(self, *, full_resolution: Optional[bool] = None): """Kicks off a background preview render task.""" if getattr(self, "_shutting_down", False): return + render_full_resolution = ( + self._should_render_live_preview_full_resolution() + if full_resolution is None + else full_resolution + ) + with self._preview_lock: if self._preview_inflight: self._preview_pending = True @@ -8249,7 +8298,10 @@ def _kick_preview_worker(self): # Submit task to dedicated preview executor try: fut = self._preview_executor.submit( - self._render_preview_worker, token, self.image_editor + self._render_preview_worker, + token, + self.image_editor, + render_full_resolution, ) fut.add_done_callback(self._on_preview_done) except RuntimeError: @@ -8258,11 +8310,15 @@ def _kick_preview_worker(self): self._preview_inflight = False @staticmethod - def _render_preview_worker(token, image_editor): + def _render_preview_worker(token, image_editor, full_resolution: bool = False): # Heavy work (PIL apply_edits) happens here off-thread try: - # allow_compute=True ensures we actually do the work - decoded = image_editor.get_preview_data_cached(allow_compute=True) + decoded = None + if full_resolution: + decoded = image_editor.get_full_resolution_preview_data() + if decoded is None: + # allow_compute=True ensures we actually do the work + decoded = image_editor.get_preview_data_cached(allow_compute=True) return token, decoded except Exception: log.exception("Preview render failed") @@ -8621,18 +8677,21 @@ def execute_crop(self): self.image_editor.set_crop_box(crop_box_raw) self.image_editor.set_edit_param("straighten_angle", current_rotation) - # Render the cropped preview synchronously and publish it as the - # current loupe image before clearing crop mode. This guarantees the - # QML side re-requests the image URL and the ImageProvider serves the - # cropped preview instead of a stale cached full-size frame. + # Render the committed crop from the full-resolution master and publish + # it before clearing crop mode, so the loupe does not keep showing the + # preview-resolution crop used while dragging the overlay. try: - decoded = self.image_editor.get_preview_data_cached(allow_compute=True) + decoded = self.image_editor.get_full_resolution_preview_data() except Exception: - log.exception("execute_crop: synchronous preview render failed") + log.exception("execute_crop: full-resolution render failed") decoded = None if decoded is not None: with self._preview_lock: + # Older preview-worker results may still be in flight from crop + # setup/rotation. Invalidate them so they cannot overwrite this + # full-resolution committed crop. + self._preview_token += 1 self._last_rendered_preview = decoded self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index 7912b9e..b1a1826 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -1773,25 +1773,39 @@ def get_preview_data_cached( if base is None: return None - # Heavy computation outside lock using snapshot - # base is float32 (H, W, 3) 0-1 - arr = self._apply_edits(base, edits=edits, for_export=False) + decoded = self._render_decoded_from_float( + base, + edits=edits, + for_export=False, + ) + + with self._lock: + # Only cache if revision hasn't changed during computation + if self._edits_rev == rev: + self._cached_preview = decoded + self._cached_rev = rev + + return decoded - # Convert to 8-bit for display - # Global shoulder is now applied in linear space within _apply_edits() - # Just clip to 0-1 as safety clamp + def _render_decoded_from_float( + self, + base: np.ndarray, + *, + edits: Dict[str, Any], + for_export: bool, + ) -> DecodedImage: + """Render edits against a float RGB array and package it for Qt display.""" + arr = self._apply_edits(base, edits=edits, for_export=for_export) arr = np.clip(arr, 0.0, 1.0) - # Map to 0-255 arr_u8 = (arr * 255).astype(np.uint8) if QImage is None: raise ImportError( - "PySide6.QtGui.QImage is required for get_preview_data_cached" + "PySide6.QtGui.QImage is required for rendering decoded image data" ) - # Create QImage from buffer img_buffer = arr_u8.tobytes() - decoded = DecodedImage( + return DecodedImage( buffer=memoryview(img_buffer), width=arr_u8.shape[1], height=arr_u8.shape[0], @@ -1799,13 +1813,24 @@ def get_preview_data_cached( format=QImage.Format.Format_RGB888, ) + def get_full_resolution_preview_data(self) -> Optional[DecodedImage]: + """Apply current edits to the full-resolution master for live display.""" + try: + self._ensure_float_image() + except RuntimeError: + return None + with self._lock: - # Only cache if revision hasn't changed during computation - if self._edits_rev == rev: - self._cached_preview = decoded - self._cached_rev = rev + if self.float_image is None: + return None + base = self.float_image.copy() + edits = dict(self.current_edits) - return decoded + return self._render_decoded_from_float( + base, + edits=edits, + for_export=True, + ) def get_preview_data(self) -> Optional[DecodedImage]: """Apply current edits and return the data as a DecodedImage.""" From 29ba910c8cd00a5dff8549dacecfb38cbea2f497 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Wed, 27 May 2026 23:09:18 -0700 Subject: [PATCH 03/13] Fix crop bug where it would look too saturated / red --- README.md | 3 +- faststack/app.py | 12 +++++++ faststack/imaging/editor.py | 11 ++++++- faststack/imaging/prefetch.py | 62 +++++++++++++++++++++++++++++++++++ faststack/qml/Main.qml | 1 + faststack/ui/keystrokes.py | 6 ++++ 6 files changed, 93 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c777b2f..0b6baca 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. - **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) - **Background Darkening:** Mask-based background darkening tool (K key) with smart edge detection, subject protection, and multiple modes. Paint rough background hints and the tool refines them into natural-looking dark backgrounds. -- **Quick Auto Adjust:** Press `l` for quick auto-levels, `L` for auto white balance + auto-levels together, `A` for auto white balance, `-`/`_` to keep adjusting the highlight/white side in 14-point steps, and `=` to deepen the shadow side in 7-point steps. These update the live in-memory edit session immediately and save once when you navigate away, start a drag, or explicitly save. +- **Quick Auto Adjust:** Press `l` for quick auto-levels, `L` for auto white balance + auto-levels together, `A` for auto white balance, `-`/`_` to keep adjusting the highlight/white side in 14-point steps, and `=`/`+` to adjust the shadow/black side in 7-point steps. These update the live in-memory edit session immediately and save once when you navigate away, start a drag, or explicitly save. - **Photoshop / Gimp Integration:** Edit current image in Photoshop or Gimp (P key) - always uses RAW files when available. - **Clipboard Support:** Copy image path to clipboard (Ctrl+C) - **Image Filtering:** Filter images by filename @@ -144,6 +144,7 @@ If you do nothing, FastStack will still run, but JPEG decoding and thumbnail gen - `L`: Quick auto white balance + auto levels (live session; saved on navigation, drag, or Ctrl+S) - `-`: Darken the current auto-adjust highlights/whites by 14 points in the live session - `_`: Raise the current auto-adjust whites by 14 points in the live session +- `+`: Raise the current auto-adjust shadows/blacks by 7 points in the live session - `=`: Deepen the current auto-adjust shadows/background by 7 points in the live session - `E`: Toggle Image Editor - `Esc`: Close active dialog, editor, cancel crop, or exit fullscreen diff --git a/faststack/app.py b/faststack/app.py index c8b37f7..79e0c41 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -2392,6 +2392,8 @@ def _format_auto_levels_detail( black_step_points = round(_AUTO_ADJUST_BLACK_STEP * 100) if extra_black_steps > 0: suffixes.append(f"blacks -{extra_black_steps * black_step_points}pt") + elif extra_black_steps < 0: + suffixes.append(f"blacks +{-extra_black_steps * black_step_points}pt") if suffixes: msg = f"{msg}; {', '.join(suffixes)}" return msg @@ -8914,6 +8916,16 @@ def deepen_auto_adjust_blacks(self): state.extra_black_steps += 1 self._apply_auto_adjust_preview(state) + @Slot() + def raise_auto_adjust_blacks(self): + """Raise the shadow side by one fixed step in the live session.""" + state = self._ensure_or_seed_active_auto_adjust_state() + if state is None: + return + + state.extra_black_steps -= 1 + self._apply_auto_adjust_preview(state) + def _apply_auto_adjust_preview(self, state: ActiveAutoAdjustState) -> None: """Render the current auto-adjust state into the editor/UI immediately.""" blacks, whites = self._derive_auto_adjust_levels(state) diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index b1a1826..c9d4318 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -28,6 +28,7 @@ _srgb_to_linear, ) from faststack.imaging.orientation import apply_orientation_to_np, get_exif_orientation +from faststack.imaging.prefetch import apply_loupe_color_correction from faststack.models import DecodedImage try: @@ -1793,11 +1794,18 @@ def _render_decoded_from_float( *, edits: Dict[str, Any], for_export: bool, + apply_loupe_color: bool = False, ) -> DecodedImage: """Render edits against a float RGB array and package it for Qt display.""" arr = self._apply_edits(base, edits=edits, for_export=for_export) arr = np.clip(arr, 0.0, 1.0) arr_u8 = (arr * 255).astype(np.uint8) + if apply_loupe_color: + icc_bytes = None + with self._lock: + if self.original_image is not None: + icc_bytes = self.original_image.info.get("icc_profile") + arr_u8 = apply_loupe_color_correction(arr_u8, icc_bytes=icc_bytes) if QImage is None: raise ImportError( @@ -1809,7 +1817,7 @@ def _render_decoded_from_float( buffer=memoryview(img_buffer), width=arr_u8.shape[1], height=arr_u8.shape[0], - bytes_per_line=arr_u8.shape[1] * 3, + bytes_per_line=arr_u8.strides[0], format=QImage.Format.Format_RGB888, ) @@ -1830,6 +1838,7 @@ def get_full_resolution_preview_data(self) -> Optional[DecodedImage]: base, edits=edits, for_export=True, + apply_loupe_color=True, ) def get_preview_data(self) -> Optional[DecodedImage]: diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index e38862f..33748b1 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -236,6 +236,68 @@ def apply_saturation_compensation( rgb_region[:] = rgb.reshape(height, width * 3).astype(np.uint8) +def apply_loupe_color_correction( + buffer: np.ndarray, + *, + icc_bytes: Optional[bytes] = None, + color_mode: Optional[str] = None, +) -> np.ndarray: + """Apply the same display-only color correction used by the loupe decode path.""" + corrected = np.ascontiguousarray(buffer) + mode = ( + color_mode + if color_mode is not None + else config.get("color", "mode", fallback="none") + ).lower() + + if mode == "icc": + monitor_profile = get_monitor_profile() + monitor_icc_path = config.get("color", "monitor_icc_path", fallback="").strip() + if monitor_profile is None: + return corrected + + src_profile = None + src_profile_key = None + if icc_bytes: + try: + src_profile = ImageCms.ImageCmsProfile(io.BytesIO(icc_bytes)) + src_profile_key = hashlib.sha256(icc_bytes).hexdigest() + except Exception as e: + log.warning("Failed to parse ICC profile: %s", e) + + if src_profile is None: + src_profile = SRGB_PROFILE + src_profile_key = "srgb_builtin" + + try: + img = PILImage.fromarray(corrected) + transform = get_icc_transform( + src_profile, + monitor_profile, + src_profile_key, + monitor_icc_path, + ) + ImageCms.applyTransform(img, transform, inPlace=True) + return np.ascontiguousarray(np.array(img, dtype=np.uint8)) + except Exception as e: + log.warning("ICC conversion failed: %s", e) + return corrected + + if mode == "saturation": + val = config.get("color", "saturation_factor", fallback="1.0") + saturation_factor = float(val) if val is not None else 1.0 + if saturation_factor != 1.0: + apply_saturation_compensation( + corrected.ravel(), + corrected.shape[1], + corrected.shape[0], + corrected.strides[0], + saturation_factor, + ) + + return corrected + + class Prefetcher: def __init__( self, diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 903503d..4fb46bb 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1730,6 +1730,7 @@ ApplicationWindow { "  L: Quick auto white balance + auto levels (live)
" + "  -: Darken current auto-adjust highlights/whites (live)
" + "  _: Raise current auto-adjust whites (live)
" + + "  +: Raise current auto-adjust shadows/blacks (live)
" + "  =: Deepen current auto-adjust shadows/background (live)
" + "  K: Background Darkening Tool
" + "  O (or right-click): Toggle crop mode
" + diff --git a/faststack/ui/keystrokes.py b/faststack/ui/keystrokes.py index 61c57e6..8e6c499 100644 --- a/faststack/ui/keystrokes.py +++ b/faststack/ui/keystrokes.py @@ -124,6 +124,9 @@ def handle_key_press(self, event): if text == "_": self._call("raise_auto_adjust_whites") return True + if text == "+": + self._call("raise_auto_adjust_blacks") + return True if text == "=": self._call("deepen_auto_adjust_blacks") return True @@ -133,6 +136,9 @@ def handle_key_press(self, event): if key == Qt.Key_Underscore: self._call("raise_auto_adjust_whites") return True + if key == Qt.Key_Plus: + self._call("raise_auto_adjust_blacks") + return True if key == Qt.Key_Equal and modifiers == Qt.NoModifier: self._call("deepen_auto_adjust_blacks") return True From 1ba77bcae7d8f7f014fd95301bb8b14c09f9bd8c Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Wed, 27 May 2026 23:18:02 -0700 Subject: [PATCH 04/13] Fix esc to cancel crop --- faststack/qml/Main.qml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 4fb46bb..bfc2054 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1108,10 +1108,19 @@ ApplicationWindow { Shortcut { sequence: "Escape" context: Qt.ApplicationShortcut - enabled: root.fullScreenLoupe + enabled: root.fullScreenLoupe && (!root.uiStateRef || !root.uiStateRef.isCropping) onActivated: root.exitFullScreenLoupe() } + Shortcut { + sequence: "Escape" + context: Qt.ApplicationShortcut + enabled: root.uiStateRef ? root.uiStateRef.isCropping && !root.uiStateRef.isDialogOpen : false + onActivated: { + if (root.controllerRef) root.controllerRef.cancel_crop_mode() + } + } + Shortcut { sequence: "E" context: Qt.ApplicationShortcut From 0f5c73c4552aa7251b3dd9f24d3daa6b9d37dc0c Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 15:24:32 -0700 Subject: [PATCH 05/13] Warn if user quits with photos in batch queue --- faststack/qml/Main.qml | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index bfc2054..803dd80 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -66,15 +66,6 @@ ApplicationWindow { } onClosing: function(close) { - if (!root.allowCloseWithRecycleBins - && root.uiStateRef - && root.uiStateRef.hasRecycleBinItems) { - close.accepted = false - root.uiStateRef.refreshRecycleBinStats() - recycleBinCleanupDialog.open() - return - } - if (!root.allowCloseWithBatches && root.controllerRef) { var definedBatchCount = root.controllerRef.get_defined_batch_count() if (definedBatchCount > 0) { @@ -85,8 +76,18 @@ ApplicationWindow { } } + if (!root.allowCloseWithRecycleBins + && root.uiStateRef + && root.uiStateRef.hasRecycleBinItems) { + close.accepted = false + root.uiStateRef.refreshRecycleBinStats() + recycleBinCleanupDialog.open() + return + } + if (root.controllerRef && !root.controllerRef.prepare_for_app_close()) { close.accepted = false + root.allowCloseWithBatches = false return } @@ -1912,6 +1913,11 @@ ApplicationWindow { } onOpened: refreshBinInfo() + onClosed: { + if (!root.allowCloseWithRecycleBins) { + root.allowCloseWithBatches = false + } + } // Ensure the dialog is fully opaque and has a solid background background: Rectangle { @@ -2198,7 +2204,10 @@ ApplicationWindow { MouseArea { anchors.fill: parent hoverEnabled: true - onClicked: recycleBinCleanupDialog.close() + onClicked: { + root.allowCloseWithBatches = false + recycleBinCleanupDialog.close() + } cursorShape: Qt.PointingHandCursor onEntered: parent.color = root.isDarkTheme ? "#2a2a2a" : "#eeeeee" onExited: parent.color = "transparent" From 202f79f944b4c4d5143644d3f230733fee723911 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 16:19:09 -0700 Subject: [PATCH 06/13] Fix live crop preview and crop cancel transaction handling --- faststack/app.py | 234 +++++++++++++++++++++++++++++++++------ faststack/ui/provider.py | 29 ++++- 2 files changed, 225 insertions(+), 38 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 79e0c41..a059b55 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -275,6 +275,13 @@ def __init__( self._auto_adjust_save_pending_action: Optional[str] = None self._auto_adjust_save_in_progress: bool = False self._live_edit_session_state: Optional[LiveEditSessionState] = None + self._crop_mode_has_saved_geometry: bool = False + self._crop_mode_saved_crop_box: Optional[Tuple[int, int, int, int]] = None + self._crop_mode_saved_straighten_angle: float = 0.0 + self._crop_mode_saved_rotation: int = 0 + self._crop_mode_saved_edit_revision: int = 0 + self._crop_mode_saved_path_key: Optional[str] = None + self._crop_mode_saved_session_id: Optional[str] = None # target_path -> save request awaiting retry. Set when a background save # for a session the user has navigated away from fails permanently; # flushed synchronously on shutdown so unsaved edits are not lost. @@ -1540,31 +1547,8 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: ) return None - # Debug preview condition - if self.ui_state.isEditorOpen or self.ui_state.isCropping: - # Robust path comparison - editor_path = self.image_editor.current_filepath - file_path = self.image_files[index].path - - match = False - if editor_path and file_path: - try: - match = Path(editor_path).resolve() == Path(file_path).resolve() - except (OSError, ValueError): - match = str(editor_path) == str(file_path) - - if not match: - # Debug log if mismatch - log.debug( - "Path mismatch in preview. Editor: %s, File: %s", - editor_path, - file_path, - ) - - # Return background-rendered preview if Editor is open OR Cropping is active - if match and self.image_editor.original_image: - if self._last_rendered_preview: - return self._last_rendered_preview + if self._has_current_live_preview_for_index(index): + return self._last_rendered_preview _, _, display_gen = self.get_display_info() @@ -1908,6 +1892,18 @@ def _get_current_live_edit_session_info( int(getattr(self.image_editor, "_edits_rev", 0)), ) + def _has_current_live_preview_for_index(self, index: int) -> bool: + """Return True when the rendered preview belongs to the current live edit session.""" + if self._last_rendered_preview is None: + return False + if index != self.current_index: + return False + if self._last_rendered_preview_index != index: + return False + if self._get_current_live_edit_session_info() is None: + return False + return self._current_live_session_has_meaningful_edits() + def _ensure_live_edit_session_state(self, *, force_reset: bool = False) -> None: """Bind dirty-session tracking to the currently loaded editor session.""" info = self._get_current_live_edit_session_info() @@ -2189,6 +2185,7 @@ def _clear_active_auto_adjust_state( log.debug("Cleared active auto-adjust state: %s", reason) if clear_editor and self.image_editor: + self._clear_crop_mode_snapshot() self.image_editor.clear() def _has_valid_active_auto_adjust_state(self) -> bool: @@ -2560,6 +2557,10 @@ def _prepare_current_session_save_request( ) -> Optional[dict[str, Any]]: """Capture an immutable save request for the current live editor session.""" self._last_save_prepare_error = None + if self._crop_mode_has_saved_geometry or ( + self.ui_state and getattr(self.ui_state, "isCropping", False) is True + ): + self._cancel_crop_transaction_for_session_boundary() if not self.image_editor.original_image: return None @@ -4445,8 +4446,155 @@ def toggle_stack_membership(self): self.ui_state.stackSummaryChanged.emit() self.sync_ui_state() - def _reset_crop_only(self): + @staticmethod + def _normalize_crop_box_tuple( + crop_box: Any, + ) -> Optional[Tuple[int, int, int, int]]: + if crop_box is None: + return None + try: + if hasattr(crop_box, "toVariant"): + crop_box = crop_box.toVariant() + if isinstance(crop_box, list): + crop_box = tuple(crop_box) + if not isinstance(crop_box, tuple) or len(crop_box) != 4: + return None + left, top, right, bottom = [ + max(0, min(1000, int(value))) for value in crop_box + ] + except (TypeError, ValueError): + return None + return ( + min(left, right), + min(top, bottom), + max(left, right), + max(top, bottom), + ) + + def _set_crop_overlay_box_only(self, crop_box: Tuple[int, int, int, int]) -> None: + if hasattr(self.ui_state, "set_current_crop_box_visual_only"): + self.ui_state.set_current_crop_box_visual_only(crop_box) + return + self.ui_state.currentCropBox = crop_box + + def _clear_crop_mode_snapshot(self) -> None: + self._crop_mode_has_saved_geometry = False + self._crop_mode_saved_crop_box = None + self._crop_mode_saved_straighten_angle = 0.0 + self._crop_mode_saved_rotation = 0 + self._crop_mode_saved_edit_revision = 0 + self._crop_mode_saved_path_key = None + self._crop_mode_saved_session_id = None + + def _snapshot_crop_mode_geometry(self) -> None: + edits = getattr(self.image_editor, "current_edits", None) or {} + self._crop_mode_saved_edit_revision = int( + getattr(self.image_editor, "_edits_rev", 0) + ) + self._crop_mode_saved_crop_box = self._normalize_crop_box_tuple( + edits.get("crop_box") + ) + try: + self._crop_mode_saved_straighten_angle = float( + edits.get("straighten_angle", 0.0) + ) + except (TypeError, ValueError): + self._crop_mode_saved_straighten_angle = 0.0 + try: + self._crop_mode_saved_rotation = int(edits.get("rotation", 0)) % 360 + except (TypeError, ValueError): + self._crop_mode_saved_rotation = 0 + + self._crop_mode_saved_path_key = None + if self.image_editor.current_filepath is not None: + try: + self._crop_mode_saved_path_key = self._key( + self.image_editor.current_filepath + ) + except (OSError, TypeError, ValueError): + self._crop_mode_saved_path_key = None + self._crop_mode_saved_session_id = getattr( + self.image_editor, "session_id", None + ) + self._crop_mode_has_saved_geometry = True + + def _restore_crop_mode_geometry(self) -> bool: + if not self._crop_mode_has_saved_geometry: + return False + if not self.image_editor: + return False + + if self._crop_mode_saved_path_key and self.image_editor.current_filepath: + try: + current_path_key = self._key(self.image_editor.current_filepath) + except (OSError, TypeError, ValueError): + current_path_key = None + if current_path_key != self._crop_mode_saved_path_key: + return False + + saved_session_id = self._crop_mode_saved_session_id + if ( + saved_session_id is not None + and getattr(self.image_editor, "session_id", None) != saved_session_id + ): + return False + + saved_crop_box = self._crop_mode_saved_crop_box + saved_angle = self._crop_mode_saved_straighten_angle + saved_rotation = self._crop_mode_saved_rotation + saved_revision = self._crop_mode_saved_edit_revision + + changed = False + with self.image_editor._lock: + edits = self.image_editor.current_edits + if edits.get("crop_box") != saved_crop_box: + edits["crop_box"] = saved_crop_box + changed = True + + try: + current_angle = float(edits.get("straighten_angle", 0.0)) + except (TypeError, ValueError): + current_angle = 0.0 + if not math.isclose( + current_angle, + saved_angle, + rel_tol=1e-5, + abs_tol=1e-7, + ): + edits["straighten_angle"] = saved_angle + changed = True + + try: + current_rotation = int(edits.get("rotation", 0)) % 360 + except (TypeError, ValueError): + current_rotation = 0 + if current_rotation != saved_rotation: + edits["rotation"] = saved_rotation + changed = True + + if changed or self.image_editor._edits_rev != saved_revision: + self.image_editor._edits_rev = saved_revision + if changed: + self.image_editor._cached_preview = None + self.image_editor._cached_rev = -1 + + overlay_box = saved_crop_box or (0, 0, 1000, 1000) + self._set_crop_overlay_box_only(overlay_box) + if hasattr(self.ui_state, "cropRotation"): + self.ui_state.cropRotation = 0.0 + return True + + def _cancel_crop_transaction_for_session_boundary(self) -> None: + if self._crop_mode_has_saved_geometry: + self._restore_crop_mode_geometry() + if self.ui_state and getattr(self.ui_state, "isCropping", False) is True: + self.ui_state.isCropping = False + self._clear_crop_mode_snapshot() + + def _reset_crop_only(self, *, clear_crop_transaction: bool = True): """Resets crop settings (crop box and straighten) to default and exits crop mode, PRESERVING 90-deg rotation.""" + if clear_crop_transaction: + self._clear_crop_mode_snapshot() if self.ui_state.isCropping: self.ui_state.isCropping = False self.update_status_message("Crop mode exited") @@ -8389,10 +8537,28 @@ def _apply_preview_result(self, payload): def cancel_crop_mode(self): """Cancel crop mode without applying changes.""" if self.ui_state.isCropping: - self._reset_crop_only() - # Notify UI and kick fresh render - self.ui_refresh_generation += 1 - self._kick_preview_worker() + self._restore_crop_mode_geometry() + try: + decoded = self.image_editor.get_full_resolution_preview_data() + except Exception: + log.exception("cancel_crop_mode: restored preview render failed") + decoded = None + + if decoded is not None: + with self._preview_lock: + self._preview_token += 1 + self._last_rendered_preview = decoded + self.ui_refresh_generation += 1 + self._last_rendered_preview_index = self.current_index + self._last_rendered_preview_gen = self.ui_refresh_generation + + self.ui_state.isCropping = False + self._clear_crop_mode_snapshot() + if decoded is not None: + self.ui_state.currentImageSourceChanged.emit() + else: + self.ui_refresh_generation += 1 + self._kick_preview_worker() self.update_status_message("Crop cancelled") @Slot() @@ -8417,8 +8583,9 @@ def toggle_crop_mode(self): if not self.load_image_for_editing(): return - # Reset to full image defaults (UI and Backend) - self._reset_crop_only() + self._snapshot_crop_mode_geometry() + # Reset to full image defaults for this crop transaction only. + self._reset_crop_only(clear_crop_transaction=False) # Set isCropping to True now that reset is done self.ui_state.isCropping = True @@ -8700,11 +8867,12 @@ def execute_crop(self): self._last_rendered_preview_gen = self.ui_refresh_generation self.ui_state.isCropping = False + self._clear_crop_mode_snapshot() # Do NOT assign ui_state.currentCropBox here — its setter syncs back # into image_editor.set_crop_box(), which would overwrite the crop we # just committed. The crop overlay is already hidden by # `visible: isCropping` in QML, and re-entering crop mode resets the - # box via toggle_crop_mode -> _reset_crop_only. + # box inside a new crop transaction. self.ui_state.resetZoomPan() if decoded is not None: self.ui_state.currentImageSourceChanged.emit() diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index d3c8464..3e6354d 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -1234,8 +1234,7 @@ def currentCropBox(self) -> list: list(self._current_crop_box) if self._current_crop_box is not None else [] ) - @currentCropBox.setter - def currentCropBox(self, new_value): + def _normalize_crop_box_value(self, new_value): # Convert QJSValue or list to tuple if needed original_value = new_value try: @@ -1259,6 +1258,7 @@ def currentCropBox(self, new_value): type(original_value), e, ) + return None # only accept 4-element tuples if ( @@ -1269,10 +1269,29 @@ def currentCropBox(self, new_value): log.warning( "UIState.currentCropBox: ignoring invalid crop box %r", new_value ) + return None + return new_value + + def _set_current_crop_box_value(self, new_value) -> bool: + if self._current_crop_box == new_value: + return False + self._current_crop_box = new_value + self.current_crop_box_changed.emit(new_value) + return True + + def set_current_crop_box_visual_only(self, new_value) -> bool: + """Update the crop overlay without mutating the editor session.""" + new_value = self._normalize_crop_box_value(new_value) + if new_value is None: + return False + return self._set_current_crop_box_value(new_value) + + @currentCropBox.setter + def currentCropBox(self, new_value): + new_value = self._normalize_crop_box_value(new_value) + if new_value is None: return - if self._current_crop_box != new_value: - self._current_crop_box = new_value - self.current_crop_box_changed.emit(new_value) + if self._set_current_crop_box_value(new_value): # Sync with ImageEditor if ( hasattr(self.app_controller, "image_editor") From e9159f959d5d0ef3efe628fe916939be7aad7e0c Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 16:39:15 -0700 Subject: [PATCH 07/13] fix crop bug --- faststack/app.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index a059b55..f61283e 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -279,7 +279,6 @@ def __init__( self._crop_mode_saved_crop_box: Optional[Tuple[int, int, int, int]] = None self._crop_mode_saved_straighten_angle: float = 0.0 self._crop_mode_saved_rotation: int = 0 - self._crop_mode_saved_edit_revision: int = 0 self._crop_mode_saved_path_key: Optional[str] = None self._crop_mode_saved_session_id: Optional[str] = None # target_path -> save request awaiting retry. Set when a background save @@ -4482,15 +4481,11 @@ def _clear_crop_mode_snapshot(self) -> None: self._crop_mode_saved_crop_box = None self._crop_mode_saved_straighten_angle = 0.0 self._crop_mode_saved_rotation = 0 - self._crop_mode_saved_edit_revision = 0 self._crop_mode_saved_path_key = None self._crop_mode_saved_session_id = None def _snapshot_crop_mode_geometry(self) -> None: edits = getattr(self.image_editor, "current_edits", None) or {} - self._crop_mode_saved_edit_revision = int( - getattr(self.image_editor, "_edits_rev", 0) - ) self._crop_mode_saved_crop_box = self._normalize_crop_box_tuple( edits.get("crop_box") ) @@ -4542,8 +4537,6 @@ def _restore_crop_mode_geometry(self) -> bool: saved_crop_box = self._crop_mode_saved_crop_box saved_angle = self._crop_mode_saved_straighten_angle saved_rotation = self._crop_mode_saved_rotation - saved_revision = self._crop_mode_saved_edit_revision - changed = False with self.image_editor._lock: edits = self.image_editor.current_edits @@ -4572,11 +4565,10 @@ def _restore_crop_mode_geometry(self) -> bool: edits["rotation"] = saved_rotation changed = True - if changed or self.image_editor._edits_rev != saved_revision: - self.image_editor._edits_rev = saved_revision - if changed: - self.image_editor._cached_preview = None - self.image_editor._cached_rev = -1 + if changed: + self.image_editor._edits_rev += 1 + self.image_editor._cached_preview = None + self.image_editor._cached_rev = -1 overlay_box = saved_crop_box or (0, 0, 1000, 1000) self._set_crop_overlay_box_only(overlay_box) From 04e625fdba1c511c1d164fdd54a59f36687d3a48 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 16:45:03 -0700 Subject: [PATCH 08/13] fix crop bug so auto-levels can not be run when cropping --- faststack/app.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/faststack/app.py b/faststack/app.py index f61283e..0f89a2d 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1846,6 +1846,15 @@ def _get_current_auto_adjust_path(self) -> Optional[Path]: except (IndexError, OSError, TypeError, ValueError): return None + def _block_auto_adjust_during_crop(self) -> bool: + """Return True after blocking auto-adjust while crop selection is transient.""" + if self.ui_state and getattr(self.ui_state, "isCropping", False) is True: + self.update_status_message( + "Apply or cancel the crop before auto-adjusting." + ) + return True + return False + def _schedule_auto_adjust_save(self, action_type: str) -> None: """Legacy no-op: quick auto-adjust saves are now session-based.""" self._auto_adjust_save_pending_action = None @@ -8878,6 +8887,8 @@ def execute_crop(self): @Slot() def auto_levels(self): """Calculates and applies auto levels (preview only). Returns False if skipped.""" + if self._block_auto_adjust_during_crop(): + return False if not self.image_files or self.current_index >= len(self.image_files): self.update_status_message("No image to adjust") return False @@ -8930,6 +8941,8 @@ def auto_levels(self): def _seed_active_auto_adjust_state(self) -> Optional[ActiveAutoAdjustState]: """Create transient auto-adjust state from the current loaded image.""" + if self._block_auto_adjust_during_crop(): + return None recommendation = self._compute_auto_levels_recommendation() state = self._build_active_auto_adjust_state(recommendation) self._active_auto_adjust_state = state @@ -8985,6 +8998,8 @@ def _apply_and_save_active_auto_adjust( @Slot() def quick_auto_levels(self): """Apply auto levels to the live session without saving yet.""" + if self._block_auto_adjust_during_crop(): + return if not self.image_files: self.update_status_message("No image to adjust") return @@ -9006,6 +9021,8 @@ def quick_auto_levels(self): @Slot() def quick_auto_adjust(self): """Apply AWB and auto-levels to the live session without saving yet.""" + if self._block_auto_adjust_during_crop(): + return if not self.image_files: self.update_status_message("No image to adjust") return @@ -9040,6 +9057,8 @@ def _ensure_or_seed_active_auto_adjust_state( self, ) -> Optional[ActiveAutoAdjustState]: """Reuse the live transient state or create a fresh one from current pixels.""" + if self._block_auto_adjust_during_crop(): + return None if self._has_valid_active_auto_adjust_state(): return self._active_auto_adjust_state if self._ensure_active_image_loaded_for_auto_adjust() is None: @@ -9049,6 +9068,8 @@ def _ensure_or_seed_active_auto_adjust_state( @Slot() def reduce_auto_adjust_highlights(self): """Darken the highlight side by one fixed step in the live session.""" + if self._block_auto_adjust_during_crop(): + return state = self._ensure_or_seed_active_auto_adjust_state() if state is None: return @@ -9059,6 +9080,8 @@ def reduce_auto_adjust_highlights(self): @Slot() def raise_auto_adjust_whites(self): """Raise the white side by one fixed step in the live session.""" + if self._block_auto_adjust_during_crop(): + return state = self._ensure_or_seed_active_auto_adjust_state() if state is None: return @@ -9069,6 +9092,8 @@ def raise_auto_adjust_whites(self): @Slot() def deepen_auto_adjust_blacks(self): """Deepen the shadow side by one fixed step in the live session.""" + if self._block_auto_adjust_during_crop(): + return state = self._ensure_or_seed_active_auto_adjust_state() if state is None: return @@ -9079,6 +9104,8 @@ def deepen_auto_adjust_blacks(self): @Slot() def raise_auto_adjust_blacks(self): """Raise the shadow side by one fixed step in the live session.""" + if self._block_auto_adjust_during_crop(): + return state = self._ensure_or_seed_active_auto_adjust_state() if state is None: return @@ -9191,6 +9218,8 @@ def _apply_auto_levels_at_index(self, index: int) -> bool: def batch_auto_levels(self): """Auto-level every image in the current batch, one at a time via event loop.""" + if self._block_auto_adjust_during_crop(): + return batch_indices = sorted(self._get_batch_indices()) if not batch_indices: self.update_status_message("No images in batch.") @@ -9274,6 +9303,8 @@ def _batch_auto_levels_done(self): @Slot() def quick_auto_white_balance(self): """Quickly apply auto white balance to the live session without saving yet.""" + if self._block_auto_adjust_during_crop(): + return if not self.image_files: self.update_status_message("No image to adjust") return @@ -9295,6 +9326,8 @@ def auto_white_balance(self) -> Optional[str]: Returns the detail message string if a correction was applied, or None if no change / error. """ + if self._block_auto_adjust_during_crop(): + return None mode = config.get("awb", "mode", fallback="lab") if mode == "lab": return self.auto_white_balance_lab() From 9ed25c85acebe1cc215a227d19a4b013b3ced2dc Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 17:57:57 -0700 Subject: [PATCH 09/13] Add --loupe command line option and update command line documentation --- README.md | 26 +++++++++++++++ faststack/app.py | 65 ++++++++++++++++++++++++++++++++++-- faststack/qml/Components.qml | 12 +++++++ faststack/qml/Main.qml | 7 ++-- faststack/ui/provider.py | 8 +++++ 5 files changed, 112 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0b6baca..cf551e2 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ FastStack performs best on Python 3.12 due to PySide6 compatibility. 4. **Run:** ```bash faststack + faststack --loupe /path/to/photos # start in loupe view, skip initial thumbnails ``` ### Windows / Linux @@ -68,6 +69,31 @@ pip install . faststack ``` +Start directly in single-image loupe view when you want faster startup on large +folders and do not need the thumbnail grid immediately: + +```bash +faststack --loupe "C:\path\to\photos" +``` + +### Command Line Options + +```text +faststack [options] [image_dir] +python -m faststack.app [options] [image_dir] +``` + +- `image_dir`: Optional directory of images to open. If omitted, FastStack uses + the configured default directory or prompts for one. +- `--loupe`: Start directly in single-image loupe view and skip the initial + thumbnail grid refresh for faster startup on large folders. +- `--debug`: Enable verbose debug logging and timing information. +- `--debugcache`: Enable cache telemetry/debug output. +- `--debug-thumbtiming`: Enable thumbnail pipeline timing logs. Implies + `--debug`. +- `--debug-thumbtrace`: Enable detailed thumbnail pipeline trace logs. Implies + `--debug`. + ### Windows Performance Note On Windows, `PyTurboJPEG` also needs the native `libjpeg-turbo` library (`turbojpeg.dll`). diff --git a/faststack/app.py b/faststack/app.py index 0f89a2d..4ad8663 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -212,6 +212,7 @@ def __init__( debug_cache: bool = False, debug_thumb_timing: bool = False, debug_thumb_trace: bool = False, + start_in_loupe: bool = False, ): super().__init__() self.debug_thumb_timing = debug_thumb_timing @@ -379,7 +380,7 @@ def __init__( ) # Protect last_displayed_image from race conditions # -- Grid View (Thumbnail) Infrastructure -- - self._is_grid_view_active = True # Default to grid view on startup + self._is_grid_view_active = not start_in_loupe # Default to grid view self._grid_nav_history: list[Path] = ( [] ) # Stack of previous directories for back navigation @@ -471,6 +472,7 @@ def __init__( self._metadata_cache = {} self._metadata_cache_index = (-1, -1) self._exif_brief_cache: dict = {} # normalized path key → formatted EXIF string + self._native_image_size_cache: Dict[str, tuple[float, int, int]] = {} self._exif_pending_path: Optional[str] = ( None # path currently awaiting EXIF read ) @@ -1227,6 +1229,9 @@ def load(self, skip_thumbnail_refresh: bool = False): self._set_folder_loaded(True) + if not self._is_grid_view_active: + self._maybe_decode_current_image("startup-loupe") + log.info( "Load summary: scans=variant:%d grid_refreshes:%d", self._scan_count_variant, @@ -1833,6 +1838,49 @@ def get_variant_save_hint(self) -> str: return "Saving will restore to main image (backup will be created)." return "" + def get_current_display_native_size(self) -> Tuple[int, int]: + """Return native pixel dimensions for the image currently represented in loupe.""" + if ( + self._last_rendered_preview is not None + and self._last_rendered_preview_index == self.current_index + and self._current_live_session_has_geometry_edits() + ): + return ( + int(self._last_rendered_preview.width), + int(self._last_rendered_preview.height), + ) + + if not self.image_files or not ( + 0 <= self.current_index < len(self.image_files) + ): + return (0, 0) + + try: + path = ( + Path(self.view_override_path) + if self.view_override_path + else self.image_files[self.current_index].path + ) + mtime = path.stat().st_mtime + key = self._key(path) + if key is None: + return (0, 0) + + cached = self._native_image_size_cache.get(key) + if cached is not None and cached[0] == mtime: + return (cached[1], cached[2]) + + with Image.open(path) as img: + width, height = img.size + orientation = img.getexif().get(274, 1) + if orientation in (5, 6, 7, 8): + width, height = height, width + + self._native_image_size_cache[key] = (mtime, int(width), int(height)) + return (int(width), int(height)) + except (OSError, TypeError, ValueError): + return (0, 0) + def _get_current_auto_adjust_path(self) -> Optional[Path]: """Return the currently viewed file path used as the auto-adjust source.""" if not self.image_files or not ( @@ -9827,6 +9875,7 @@ def main( debug_cache: bool = False, debug_thumb_timing: bool = False, debug_thumb_trace: bool = False, + start_in_loupe: bool = False, ): """FastStack Application Entry Point""" global _debug_mode, _debug_thumb_timing, _debug_thumb_trace @@ -9885,7 +9934,7 @@ def main( print(f" Closest existing path: {check}") break check = check.parent - print("\nUsage: faststack ") + print("\nUsage: faststack [--loupe] ") sys.exit(1) app.setOrganizationName("FastStack") app.setOrganizationDomain("faststack.dev") @@ -9906,6 +9955,7 @@ def main( debug_cache=debug_cache, debug_thumb_timing=debug_thumb_timing, debug_thumb_trace=debug_thumb_trace, + start_in_loupe=start_in_loupe, ) if debug: log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) @@ -9937,7 +9987,10 @@ def main( # Defer heavy loading to after event loop starts so the window appears instantly. # controller.load() does disk scanning, image decode, and thumbnail model refresh — # all of which can run after the first event loop iteration. - QTimer.singleShot(0, controller.load) + QTimer.singleShot( + 0, + lambda: controller.load(skip_thumbnail_refresh=start_in_loupe), + ) if debug: log.info( "Startup: controller.load() deferred to event loop (%.3fs to window)", @@ -10021,6 +10074,11 @@ def cli(): parser.add_argument( "--debugcache", action="store_true", help="Enable debug cache features" ) + parser.add_argument( + "--loupe", + action="store_true", + help="Start directly in loupe view and skip initial thumbnail model refresh", + ) parser.add_argument( "--debug-thumbtiming", action="store_true", @@ -10040,6 +10098,7 @@ def cli(): debug_cache=args.debugcache, debug_thumb_timing=args.debug_thumbtiming, debug_thumb_trace=args.debug_thumbtrace, + start_in_loupe=args.loupe, ) diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 33acbb2..dcf1f93 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -20,6 +20,18 @@ Item { // Expose zoom state to parent (Main.qml title bar) readonly property real currentZoomScale: imageRotator.zoomScale readonly property real currentFitScale: imageRotator.fitScale + readonly property real currentPixelZoomScale: { + if (!loupeView.uiStateRef || !mainImage || mainImage.sourceSize.width <= 0 || mainImage.sourceSize.height <= 0) return currentZoomScale + + var nativeW = loupeView.uiStateRef.currentNativeImageWidth + var nativeH = loupeView.uiStateRef.currentNativeImageHeight + if (nativeW <= 0 || nativeH <= 0) return currentZoomScale + + var scaleW = imageRotator.zoomScale * mainImage.sourceSize.width / nativeW + var scaleH = imageRotator.zoomScale * mainImage.sourceSize.height / nativeH + if (scaleW > 0 && scaleH > 0) return Math.min(scaleW, scaleH) + return currentZoomScale + } // Freeze the displayed source for the full crop session once crop mode // starts. Zoom-triggered high-res swaps stay blocked until crop mode exits, // because any async source swap during cropping can rescale the image and diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 803dd80..07c5350 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -434,13 +434,14 @@ ApplicationWindow { property var loupe: mainViewLoader.item property real zs: loupe ? loupe.currentZoomScale : 0 property real fs: loupe ? loupe.currentFitScale : 0 + property real ps: loupe ? loupe.currentPixelZoomScale : 0 text: { - if (!loupe || fs <= 0 || zs <= 0) return "" + if (!loupe || fs <= 0 || zs <= 0 || ps <= 0) return "" if (root.uiStateRef && root.uiStateRef.isGridViewActive) return "" var ratio = zs / fs - if (Math.abs(ratio - 1.0) < 0.03) return "Zoom: Fit to window (" + Math.round(zs * 100) + "%)" - return "Zoom: " + Math.round(zs * 100) + "%" + if (Math.abs(ratio - 1.0) < 0.03) return "Zoom: Fit to window (" + Math.round(ps * 100) + "%)" + return "Zoom: " + Math.round(ps * 100) + "%" } } diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 3e6354d..d6bf090 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -484,6 +484,14 @@ def currentImageSource(self): return "" return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" + @Property(int, notify=currentImageSourceChanged) + def currentNativeImageWidth(self): + return self.app_controller.get_current_display_native_size()[0] + + @Property(int, notify=currentImageSourceChanged) + def currentNativeImageHeight(self): + return self.app_controller.get_current_display_native_size()[1] + @Property(str, notify=metadataChanged) def currentFilename(self): if not self.app_controller.image_files: From 8c494216dea0f224417e7a5b0ffc15e1b0a7b6ae Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 18:38:48 -0700 Subject: [PATCH 10/13] Allow esc to cancel crop rotation --- ChangeLog.md | 6 +++++- faststack/app.py | 7 +++++++ faststack/qml/Components.qml | 29 ++++++++++++++++++++++------- faststack/qml/Main.qml | 2 +- faststack/ui/provider.py | 13 +++++++++++++ 5 files changed, 48 insertions(+), 9 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 35d3e33..05302f6 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -8,7 +8,11 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better documentation - `l` now runs quick auto-levels, `L` runs auto white balance plus auto-levels, `A` runs quick auto white balance, `-` lowers highlights/whites in 14-point steps, `_` raises whites in 14-point steps, and `=` keeps pushing shadows in 7-point steps inside that live session. - Crop and editor edits now keep accumulating in memory on the current image instead of forcing an immediate save or backup churn. - The live session is persisted once when you navigate away, start a drag, explicitly save, or quit, so preview stays responsive while drag-out and navigation still get the latest pixels. -- Updated README/help text and kept the version at 1.6.3. +- Fixes crop preview behavior so committed crops continue to display correctly when zooming. +- Adds `+` as the inverse shadow/blacks adjustment for quick auto-adjust. +- Fixes the Actions → Sort submenu behavior by using a popup instead of a sibling menu. +- Adds `--loupe` startup mode for faster launch into single-image view on large folders. +- Updates README shortcut and command-line documentation. ## 1.6.2 (2026-03-28) diff --git a/faststack/app.py b/faststack/app.py index 4ad8663..75cb043 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -1089,6 +1089,8 @@ def eventFilter(self, watched, event) -> bool: if event.key() == Qt.Key_Escape and getattr( self.ui_state, "isCropping", False ): + if getattr(self.ui_state, "isCropRotating", False): + return False # Let the loupe leave rotate mode first. self.cancel_crop_mode() return True # Consume event, crop mode cancelled @@ -4637,6 +4639,7 @@ def _cancel_crop_transaction_for_session_boundary(self) -> None: if self._crop_mode_has_saved_geometry: self._restore_crop_mode_geometry() if self.ui_state and getattr(self.ui_state, "isCropping", False) is True: + self.ui_state.isCropRotating = False self.ui_state.isCropping = False self._clear_crop_mode_snapshot() @@ -4645,6 +4648,7 @@ def _reset_crop_only(self, *, clear_crop_transaction: bool = True): if clear_crop_transaction: self._clear_crop_mode_snapshot() if self.ui_state.isCropping: + self.ui_state.isCropRotating = False self.ui_state.isCropping = False self.update_status_message("Crop mode exited") self.ui_state.currentCropBox = (0, 0, 1000, 1000) @@ -8601,6 +8605,7 @@ def cancel_crop_mode(self): self._last_rendered_preview_index = self.current_index self._last_rendered_preview_gen = self.ui_refresh_generation + self.ui_state.isCropRotating = False self.ui_state.isCropping = False self._clear_crop_mode_snapshot() if decoded is not None: @@ -8636,6 +8641,7 @@ def toggle_crop_mode(self): # Reset to full image defaults for this crop transaction only. self._reset_crop_only(clear_crop_transaction=False) # Set isCropping to True now that reset is done + self.ui_state.isCropRotating = False self.ui_state.isCropping = True self.ui_state.aspectRatioNames = [r["name"] for r in ASPECT_RATIOS] @@ -8915,6 +8921,7 @@ def execute_crop(self): self._last_rendered_preview_index = self.current_index self._last_rendered_preview_gen = self.ui_refresh_generation + self.ui_state.isCropRotating = False self.ui_state.isCropping = False self._clear_crop_mode_snapshot() # Do NOT assign ui_state.currentCropBox here — its setter syncs back diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index dcf1f93..d685c0a 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -60,6 +60,25 @@ Item { function releaseCropImageSource() { cropDragImageSource = "" } + + function cancelActiveCropRotation() { + if (!loupeView.uiStateRef || !loupeView.uiStateRef.isCropping || !mainMouseArea.isRotating) return false + + mainMouseArea.cropRotation = mainMouseArea.cropStartRotation + mainMouseArea.clearPendingRotation(mainMouseArea.cropRotation) + if (loupeView.controllerRef) loupeView.controllerRef.set_straighten_angle(mainMouseArea.cropRotation, -1) + + mainMouseArea.endCropInteraction() + mainMouseArea.isRotating = false + return true + } + + Shortcut { + sequence: "Escape" + context: Qt.ApplicationShortcut + enabled: loupeView.uiStateRef ? loupeView.uiStateRef.isCropping && loupeView.uiStateRef.isCropRotating && !loupeView.uiStateRef.isDialogOpen : false + onActivated: loupeView.cancelActiveCropRotation() + } Connections { target: loupeView.uiStateRef @@ -89,6 +108,7 @@ Item { mainMouseArea.isRotating = false mainMouseArea.cropRotation = 0 } + if (loupeView.uiStateRef) loupeView.uiStateRef.isCropRotating = false loupeView.releaseCropImageSource() } } @@ -97,13 +117,7 @@ Item { Keys.onEscapePressed: (event) => { if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping) { if (mainMouseArea.isRotating) { - // Revert rotation - mainMouseArea.cropRotation = mainMouseArea.cropStartRotation - mainMouseArea.clearPendingRotation(mainMouseArea.cropRotation) - if (loupeView.controllerRef) loupeView.controllerRef.set_straighten_angle(mainMouseArea.cropRotation, -1) - - mainMouseArea.endCropInteraction() - mainMouseArea.isRotating = false + loupeView.cancelActiveCropRotation() event.accepted = true } else if (loupeView.controllerRef) { mainMouseArea.clearPendingRotation(0) @@ -651,6 +665,7 @@ Item { onIsRotatingChanged: { if (loupeView.uiStateRef) { + loupeView.uiStateRef.isCropRotating = isRotating && loupeView.uiStateRef.isCropping if (isRotating) { loupeView.uiStateRef.statusMessage = "Press ESC to exit rotate mode" } else { diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 07c5350..98ef5df 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1117,7 +1117,7 @@ ApplicationWindow { Shortcut { sequence: "Escape" context: Qt.ApplicationShortcut - enabled: root.uiStateRef ? root.uiStateRef.isCropping && !root.uiStateRef.isDialogOpen : false + enabled: root.uiStateRef ? root.uiStateRef.isCropping && !root.uiStateRef.isCropRotating && !root.uiStateRef.isDialogOpen : false onActivated: { if (root.controllerRef) root.controllerRef.cancel_crop_mode() } diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index d6bf090..0d958a7 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -235,6 +235,7 @@ class UIState(QObject): Signal() ) # New signal for when the image loaded in editor changes is_cropping_changed = Signal(bool) + is_crop_rotating_changed = Signal(bool) is_histogram_visible_changed = Signal(bool) histogram_data_changed = Signal() @@ -312,6 +313,7 @@ def __init__(self, app_controller, clock_func=None): # Image Editor State self._is_editor_open = False self._is_cropping = False + self._is_crop_rotating = False self._is_histogram_visible = False self._histogram_data = {} # Will be a dict with 'r', 'g', 'b' arrays self._brightness = 0.0 @@ -1045,6 +1047,17 @@ def isCropping(self, new_value: bool): self._is_cropping = new_value self.is_cropping_changed.emit(new_value) + @Property(bool, notify=is_crop_rotating_changed) + def isCropRotating(self) -> bool: + return self._is_crop_rotating + + @isCropRotating.setter + def isCropRotating(self, new_value: bool): + new_value = bool(new_value) + if self._is_crop_rotating != new_value: + self._is_crop_rotating = new_value + self.is_crop_rotating_changed.emit(new_value) + @Property(bool, notify=is_histogram_visible_changed) def isHistogramVisible(self) -> bool: return self._is_histogram_visible From b6261482c0ce8aa70705811606967ea5e3a586dd Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 19:03:32 -0700 Subject: [PATCH 11/13] Fix bugs suggested by Coderabbit --- faststack/app.py | 86 +++++++++++++++---- faststack/imaging/editor.py | 26 ++++-- faststack/qml/Components.qml | 17 ++-- faststack/ui/provider.py | 70 +++++++++++---- .../inspect_lrcat_photo.py | 6 +- lightroom-catalog-import/lrcat_diff.py | 6 +- 6 files changed, 159 insertions(+), 52 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 75cb043..5856d97 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -266,6 +266,9 @@ def __init__( self._preview_token = 0 self._preview_lock = threading.Lock() self._last_rendered_preview = None # Store latest valid render + self._last_rendered_preview_session_key: Optional[tuple[str, Optional[str]]] = ( + None + ) 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 @@ -625,7 +628,7 @@ def _on_editor_open_changed(self, is_open: bool): if not keep_preview: with self._preview_lock: - self._last_rendered_preview = None + self._clear_last_rendered_preview_locked() def is_valid_working_tif(self, path: Path) -> bool: """Checks if a working TIFF path is valid for editing.""" @@ -1781,6 +1784,8 @@ def sync_ui_state(self): if ( self._last_rendered_preview is not None and self._last_rendered_preview_index == self.current_index + and self._last_rendered_preview_session_key + == self._get_current_live_preview_session_key() ): self._last_rendered_preview_gen = self.ui_refresh_generation @@ -1846,6 +1851,8 @@ def get_current_display_native_size(self) -> Tuple[int, int]: self._last_rendered_preview is not None and self._last_rendered_preview_index == self.current_index and self._current_live_session_has_geometry_edits() + and self._last_rendered_preview_session_key + == self._get_current_live_preview_session_key() ): return ( int(self._last_rendered_preview.width), @@ -1950,6 +1957,34 @@ def _get_current_live_edit_session_info( int(getattr(self.image_editor, "_edits_rev", 0)), ) + def _get_current_live_preview_session_key( + self, + ) -> Optional[tuple[str, Optional[str]]]: + """Return the path/session identity for the active live preview.""" + info = self._get_current_live_edit_session_info() + if info is None: + return None + active_path_key, session_id, _revision = info + return (active_path_key, session_id) + + def _publish_last_rendered_preview_locked( + self, + decoded: DecodedImage, + session_key: Optional[tuple[str, Optional[str]]] = None, + ) -> None: + """Publish a rendered preview while holding _preview_lock.""" + self._last_rendered_preview = decoded + self._last_rendered_preview_session_key = ( + session_key + if session_key is not None + else self._get_current_live_preview_session_key() + ) + + def _clear_last_rendered_preview_locked(self) -> None: + """Clear rendered preview state while holding _preview_lock.""" + self._last_rendered_preview = None + self._last_rendered_preview_session_key = None + def _has_current_live_preview_for_index(self, index: int) -> bool: """Return True when the rendered preview belongs to the current live edit session.""" if self._last_rendered_preview is None: @@ -1958,7 +1993,10 @@ def _has_current_live_preview_for_index(self, index: int) -> bool: return False if self._last_rendered_preview_index != index: return False - if self._get_current_live_edit_session_info() is None: + session_key = self._get_current_live_preview_session_key() + if session_key is None: + return False + if self._last_rendered_preview_session_key != session_key: return False return self._current_live_session_has_meaningful_edits() @@ -6544,7 +6582,7 @@ def _post_undo_refresh_and_select( self.image_editor.reset_edits() self._clear_live_edit_session_state() with self._preview_lock: - self._last_rendered_preview = None + self._clear_last_rendered_preview_locked() self.refresh_image_list() @@ -6591,7 +6629,7 @@ def undo_delete(self): self.image_editor.reset_edits() self._clear_live_edit_session_state() with self._preview_lock: - self._last_rendered_preview = None + self._clear_last_rendered_preview_locked() self._bump_display_generation() if self.image_cache and 0 <= self.current_index < len(self.image_files): self.image_cache.pop_path(self.image_files[self.current_index].path) @@ -8274,9 +8312,11 @@ def _kick_histogram_worker(self): # Snap the currently known preview data to avoid racing with the editor. # Only use cached preview if it matches the current image to prevent stale histograms. - preview_data = self._last_rendered_preview - if preview_data and self._last_rendered_preview_index != self.current_index: - preview_data = None + preview_data = ( + self._last_rendered_preview + if self._has_current_live_preview_for_index(self.current_index) + else None + ) if not preview_data: # Fallback for initial load if no edit preview yet (could use get_decoded_image?) # But histogram is mostly for edits. If preview_data is None, we likely can't compute anyway. @@ -8497,12 +8537,14 @@ def _kick_preview_worker(self, *, full_resolution: Optional[bool] = None): self._preview_pending = False self._preview_token += 1 token = self._preview_token + session_key = self._get_current_live_preview_session_key() # Submit task to dedicated preview executor try: fut = self._preview_executor.submit( self._render_preview_worker, token, + session_key, self.image_editor, render_full_resolution, ) @@ -8513,7 +8555,12 @@ def _kick_preview_worker(self, *, full_resolution: Optional[bool] = None): self._preview_inflight = False @staticmethod - def _render_preview_worker(token, image_editor, full_resolution: bool = False): + def _render_preview_worker( + token, + session_key, + image_editor, + full_resolution: bool = False, + ): # Heavy work (PIL apply_edits) happens here off-thread try: decoded = None @@ -8522,29 +8569,32 @@ def _render_preview_worker(token, image_editor, full_resolution: bool = False): if decoded is None: # allow_compute=True ensures we actually do the work decoded = image_editor.get_preview_data_cached(allow_compute=True) - return token, decoded + return token, session_key, decoded except Exception: log.exception("Preview render failed") - return token, None + return token, session_key, None def _on_preview_done(self, fut): if getattr(self, "_shutting_down", False): return try: - token, decoded = fut.result() + token, session_key, decoded = fut.result() except Exception: - token, decoded = None, None + token, session_key, decoded = None, None, None # Emit from worker thread; Qt will queue to UI thread - self.previewReady.emit((token, decoded)) + self.previewReady.emit((token, session_key, decoded)) @Slot(object) def _apply_preview_result(self, payload): if getattr(self, "_shutting_down", False): return - token, decoded = payload + try: + token, session_key, decoded = payload + except (TypeError, ValueError): + token, session_key, decoded = None, None, None should_kick = False should_accept = False @@ -8559,8 +8609,10 @@ def _apply_preview_result(self, payload): decoded is not None and token == self._preview_token and not self._preview_pending + and session_key is not None + and session_key == self._get_current_live_preview_session_key() ): - self._last_rendered_preview = decoded + self._publish_last_rendered_preview_locked(decoded, session_key) self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index self._last_rendered_preview_gen = self.ui_refresh_generation @@ -8600,7 +8652,7 @@ def cancel_crop_mode(self): if decoded is not None: with self._preview_lock: self._preview_token += 1 - self._last_rendered_preview = decoded + self._publish_last_rendered_preview_locked(decoded) self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index self._last_rendered_preview_gen = self.ui_refresh_generation @@ -8916,7 +8968,7 @@ def execute_crop(self): # setup/rotation. Invalidate them so they cannot overwrite this # full-resolution committed crop. self._preview_token += 1 - self._last_rendered_preview = decoded + self._publish_last_rendered_preview_locked(decoded) self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index self._last_rendered_preview_gen = self.ui_refresh_generation diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index c9d4318..ba17555 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -1769,6 +1769,11 @@ def get_preview_data_cached( # Prepare for computation - snapshot data under lock base = self.float_preview.copy() if self.float_preview is not None else None edits = dict(self.current_edits) + icc_bytes = ( + self.original_image.info.get("icc_profile") + if self.original_image is not None + else None + ) rev = self._edits_rev if base is None: @@ -1778,6 +1783,7 @@ def get_preview_data_cached( base, edits=edits, for_export=False, + icc_bytes=icc_bytes, ) with self._lock: @@ -1795,16 +1801,19 @@ def _render_decoded_from_float( edits: Dict[str, Any], for_export: bool, apply_loupe_color: bool = False, + icc_bytes: Optional[bytes] = None, + cache_context: Optional[dict] = None, ) -> DecodedImage: """Render edits against a float RGB array and package it for Qt display.""" - arr = self._apply_edits(base, edits=edits, for_export=for_export) + arr = self._apply_edits( + base, + edits=edits, + for_export=for_export, + cache_context=cache_context, + ) arr = np.clip(arr, 0.0, 1.0) arr_u8 = (arr * 255).astype(np.uint8) if apply_loupe_color: - icc_bytes = None - with self._lock: - if self.original_image is not None: - icc_bytes = self.original_image.info.get("icc_profile") arr_u8 = apply_loupe_color_correction(arr_u8, icc_bytes=icc_bytes) if QImage is None: @@ -1833,12 +1842,19 @@ def get_full_resolution_preview_data(self) -> Optional[DecodedImage]: return None base = self.float_image.copy() edits = dict(self.current_edits) + icc_bytes = ( + self.original_image.info.get("icc_profile") + if self.original_image is not None + else None + ) return self._render_decoded_from_float( base, edits=edits, for_export=True, apply_loupe_color=True, + icc_bytes=icc_bytes, + cache_context={}, ) def get_preview_data(self) -> Optional[DecodedImage]: diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index d685c0a..b748774 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -384,17 +384,17 @@ Item { // Crop overlay - anchored to mainImage to rotate with it Item { id: cropOverlay - property bool hasActiveCrop: { + property bool isFullImageCrop: { var b = _liveCropBox() - return b && b.length === 4 && !(b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000) + return b && b.length === 4 && b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000 } - property bool hasDrawableCrop: { + property bool hasPositiveCrop: { var b = _liveCropBox() return b && b.length === 4 && (b[2] - b[0]) > 0 && (b[3] - b[1]) > 0 - && !(b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000) } - // Show visual content only when there is an actual user-drawn crop or rotate mode. - property bool showCropContent: hasActiveCrop || mainMouseArea.isRotating || mainMouseArea.isCropDragging + property bool hasDrawableCrop: hasPositiveCrop && !isFullImageCrop + // Show visual content only for a real crop box or rotate mode. + property bool showCropContent: hasDrawableCrop || mainMouseArea.isRotating property int _cropBoxRev: 0 Connections { @@ -433,7 +433,7 @@ Item { // Rotation Handle Line Rectangle { id: handleLine - visible: mainMouseArea.isRotating + visible: cropOverlay.hasDrawableCrop && mainMouseArea.isRotating width: 2 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) height: 25 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) color: "white" @@ -444,7 +444,7 @@ Item { // Rotation Knob Rectangle { id: rotateKnob - visible: mainMouseArea.isRotating + visible: cropOverlay.hasDrawableCrop && mainMouseArea.isRotating width: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) height: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) radius: width / 2 @@ -715,7 +715,6 @@ Item { cropStartX = mouseX cropStartY = mouseY setCropBoxStart(clampedMx, clampedMy, clampedMx, clampedMy) - loupeView.uiStateRef.currentCropBox = [Math.round(clampedMx), Math.round(clampedMy), Math.round(clampedMx), Math.round(clampedMy)] } function beginCropInteraction() { diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 0d958a7..ff1973f 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -2,7 +2,9 @@ import collections import logging +import math import threading +from numbers import Real from pathlib import Path from PySide6.QtCore import Property, QObject, Qt, Signal, Slot @@ -82,26 +84,24 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: # Also accept the editor-rendered preview when the live edit # session holds meaningful edits (e.g. a crop applied outside the # editor), so the main loupe reflects those edits immediately. - has_live_edits = False - live_check = getattr( - self.app_controller, "_current_live_session_has_meaningful_edits", None + has_current_live_preview = False + live_preview_check = getattr( + self.app_controller, "_has_current_live_preview_for_index", None ) - if callable(live_check): + if callable(live_preview_check): try: - has_live_edits = bool(live_check()) + has_current_live_preview = bool(live_preview_check(index)) except Exception: - has_live_edits = False + has_current_live_preview = False use_editor_preview = ( ( self.app_controller.ui_state.isEditorOpen or has_active_auto_adjust - or has_live_edits + or has_current_live_preview ) and index == self.app_controller.current_index and not self.app_controller.ui_state.isZoomed - and self.app_controller._last_rendered_preview is not None - and getattr(self.app_controller, "_last_rendered_preview_index", None) - == index + and has_current_live_preview and ( gen is None or getattr(self.app_controller, "_last_rendered_preview_gen", None) @@ -145,7 +145,7 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: if ( self.app_controller.ui_state.isEditorOpen or has_active_auto_adjust - or has_live_edits + or has_current_live_preview ) and index == self.app_controller.current_index: qimg = qimg.copy() else: @@ -1281,16 +1281,52 @@ def _normalize_crop_box_value(self, new_value): ) return None - # only accept 4-element tuples - if ( - not isinstance(new_value, tuple) - or len(new_value) != 4 - or not all(isinstance(v, (int, float)) for v in new_value) - ): + if not isinstance(new_value, tuple) or len(new_value) != 4: log.warning( "UIState.currentCropBox: ignoring invalid crop box %r", new_value ) return None + + if not all(isinstance(v, Real) and not isinstance(v, bool) for v in new_value): + log.warning( + "UIState.currentCropBox: ignoring non-numeric crop box %r", new_value + ) + return None + + try: + values = tuple(new_value) + left, top, right, bottom = values + finite_values = tuple(float(v) for v in values) + except (TypeError, ValueError) as e: + log.warning( + "UIState.currentCropBox: failed to validate crop box %r: %s", + new_value, + e, + ) + return None + + if not all(math.isfinite(v) for v in finite_values): + log.warning( + "UIState.currentCropBox: ignoring non-finite crop box %r", new_value + ) + return None + + if not all(0.0 <= v <= 1000.0 for v in finite_values): + log.warning( + "UIState.currentCropBox: ignoring out-of-range crop box %r", new_value + ) + return None + + if not (left < right and top < bottom): + # Transient during drag: QML can briefly emit zero-size or + # inverted boxes when the user reverses direction. Reject + # silently to avoid log spam. + log.debug( + "UIState.currentCropBox: ignoring inverted or zero-size crop box %r", + new_value, + ) + return None + return new_value def _set_current_crop_box_value(self, new_value) -> bool: diff --git a/lightroom-catalog-import/inspect_lrcat_photo.py b/lightroom-catalog-import/inspect_lrcat_photo.py index c620b2b..eac9ce9 100644 --- a/lightroom-catalog-import/inspect_lrcat_photo.py +++ b/lightroom-catalog-import/inspect_lrcat_photo.py @@ -69,13 +69,15 @@ def connect_ro(path: str) -> sqlite3.Connection: def get_tables(conn: sqlite3.Connection) -> list[str]: """Return all user table names in the database, sorted.""" - rows = conn.execute(""" + rows = conn.execute( + """ SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name - """).fetchall() + """ + ).fetchall() return [row["name"] for row in rows] diff --git a/lightroom-catalog-import/lrcat_diff.py b/lightroom-catalog-import/lrcat_diff.py index 21dd107..53adfe5 100644 --- a/lightroom-catalog-import/lrcat_diff.py +++ b/lightroom-catalog-import/lrcat_diff.py @@ -81,12 +81,14 @@ def connect_ro(path: str) -> sqlite3.Connection: def get_tables(conn: sqlite3.Connection) -> set[str]: """Return all user table names in the database.""" - rows = conn.execute(""" + rows = conn.execute( + """ SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' - """).fetchall() + """ + ).fetchall() return {row["name"] for row in rows} From 141bf16555f0ad58502e3cfe55e56012e099340a Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 21:54:40 -0700 Subject: [PATCH 12/13] fix escape key to cancel crop + disable editor while in crop mode --- faststack/app.py | 9 ++++++++ faststack/qml/Components.qml | 9 +------- faststack/qml/Main.qml | 10 ++++++++ faststack/ui/provider.py | 44 +++++++++++++++++++++++++++++++----- 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 5856d97..0c7fe89 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -7681,6 +7681,9 @@ def load_image_for_editing(self): was kept, or False on failure. The @Slot annotation coerces _REUSED to true for QML callers (none of which inspect the value). """ + if self.ui_state.isCropping: + self.update_status_message("Apply or cancel the crop before editing") + return False try: if self.view_override_path: active_path = Path(self.view_override_path) @@ -8652,6 +8655,7 @@ def cancel_crop_mode(self): if decoded is not None: with self._preview_lock: self._preview_token += 1 + self._preview_pending = False self._publish_last_rendered_preview_locked(decoded) self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index @@ -8674,6 +8678,10 @@ def toggle_crop_mode(self): # Exiting crop mode: reuse the specialized cleanup self.cancel_crop_mode() else: + if self.ui_state.isEditorOpen: + self.update_status_message("Close the editor before cropping") + return + # Entering crop mode requires a loaded image with a valid float buffer. if not self.image_files or not ( 0 <= self.current_index < len(self.image_files) @@ -8968,6 +8976,7 @@ def execute_crop(self): # setup/rotation. Invalidate them so they cannot overwrite this # full-resolution committed crop. self._preview_token += 1 + self._preview_pending = False self._publish_last_rendered_preview_locked(decoded) self.ui_refresh_generation += 1 self._last_rendered_preview_index = self.current_index diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index b748774..64df933 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -73,13 +73,6 @@ Item { return true } - Shortcut { - sequence: "Escape" - context: Qt.ApplicationShortcut - enabled: loupeView.uiStateRef ? loupeView.uiStateRef.isCropping && loupeView.uiStateRef.isCropRotating && !loupeView.uiStateRef.isDialogOpen : false - onActivated: loupeView.cancelActiveCropRotation() - } - Connections { target: loupeView.uiStateRef function onCurrentIndexChanged() { @@ -123,7 +116,7 @@ Item { mainMouseArea.clearPendingRotation(0) mainMouseArea.endCropInteraction() loupeView.controllerRef.cancel_crop_mode() - mainMouseArea.cropRotation = 0 // Reset local rotation + mainMouseArea.cropRotation = 0 mainMouseArea.isRotating = false event.accepted = true } diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 98ef5df..00b3e7f 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -793,6 +793,11 @@ ApplicationWindow { defaultTextColor: root.currentTextColor onClicked: { if (root.uiStateRef) { + if (root.uiStateRef.isCropping) { + root.uiStateRef.statusMessage = "Apply or cancel the crop before editing" + actionsMenu.close() + return + } root.uiStateRef.isEditorOpen = !root.uiStateRef.isEditorOpen if (root.uiStateRef.isEditorOpen && root.controllerRef) { root.controllerRef.load_image_for_editing() @@ -1130,6 +1135,11 @@ ApplicationWindow { onActivated: { if (!root.uiStateRef) return + if (root.uiStateRef.isCropping) { + root.uiStateRef.statusMessage = "Apply or cancel the crop before editing" + return + } + if (root.uiStateRef.isEditorOpen) { root.uiStateRef.isEditorOpen = false } else { diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index ff1973f..3245473 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -93,6 +93,43 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: has_current_live_preview = bool(live_preview_check(index)) except Exception: has_current_live_preview = False + # Whether there is a usable rendered preview buffer for this index. + # The stored session key must match the current live edit session so + # a preview from an old/replaced editor session can never be served. + # This is independent of the meaningful-edits requirement, so a valid + # editor-open preview displays without satisfying the stricter + # _has_current_live_preview_for_index() check a second time. + current_preview_session_key = None + get_preview_key = getattr( + self.app_controller, + "_get_current_live_preview_session_key", + None, + ) + if callable(get_preview_key): + try: + current_preview_session_key = get_preview_key() + except Exception: + current_preview_session_key = None + + has_valid_preview_buffer = ( + current_preview_session_key is not None + and self.app_controller._last_rendered_preview is not None + and self.app_controller._last_rendered_preview_index == index + and getattr( + self.app_controller, "_last_rendered_preview_session_key", None + ) + == current_preview_session_key + and ( + gen is None + or getattr(self.app_controller, "_last_rendered_preview_gen", None) + == gen + ) + ) + + # A committed live preview (session-key match) is sufficient on its + # own. When the editor is open or an auto-adjust session is active we + # also accept the editor-rendered preview, provided a valid buffer + # exists for the current index. use_editor_preview = ( ( self.app_controller.ui_state.isEditorOpen @@ -101,12 +138,7 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: ) and index == self.app_controller.current_index and not self.app_controller.ui_state.isZoomed - and has_current_live_preview - and ( - gen is None - or getattr(self.app_controller, "_last_rendered_preview_gen", None) - == gen - ) + and has_valid_preview_buffer ) if _debug: From 32de4e8216c768673a875f9e7e8add82e83f0c42 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 28 May 2026 22:27:25 -0700 Subject: [PATCH 13/13] Fix histogram bug --- faststack/app.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 0c7fe89..f4bd146 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -8266,6 +8266,9 @@ def update_histogram( self._hist_pending = None return + if self.ui_state.isCropping: + return + with self._hist_lock: self._hist_pending = (zoom, pan_x, pan_y, image_scale) self._hist_null_retries = 0 # Fresh request resets retry counter @@ -8590,6 +8593,13 @@ def _on_preview_done(self, fut): self.previewReady.emit((token, session_key, decoded)) @Slot(object) + def _emit_preview_accepted_side_effects(self): + self.ui_state.currentImageSourceChanged.emit() + self.ui_state.highlightStateChanged.emit() + self.update_histogram() + if self.ui_state._is_darkening: + self._update_darken_overlay() + def _apply_preview_result(self, payload): if getattr(self, "_shutting_down", False): return @@ -8628,12 +8638,7 @@ def _apply_preview_result(self, payload): # Emit outside lock to avoid holding lock during UI work if should_accept: - self.ui_state.currentImageSourceChanged.emit() - self.ui_state.highlightStateChanged.emit() - self.update_histogram() - # Keep mask overlay in sync with the preview whenever it changes - if self.ui_state._is_darkening: - self._update_darken_overlay() + self._emit_preview_accepted_side_effects() # Call directly (not via singleShot) since we're on the UI thread. # This prevents race where a new slider event could interleave between @@ -8665,7 +8670,7 @@ def cancel_crop_mode(self): self.ui_state.isCropping = False self._clear_crop_mode_snapshot() if decoded is not None: - self.ui_state.currentImageSourceChanged.emit() + self._emit_preview_accepted_side_effects() else: self.ui_refresh_generation += 1 self._kick_preview_worker() @@ -8992,11 +8997,9 @@ def execute_crop(self): # box inside a new crop transaction. self.ui_state.resetZoomPan() if decoded is not None: - self.ui_state.currentImageSourceChanged.emit() + self._emit_preview_accepted_side_effects() else: self._kick_preview_worker() - if self.ui_state.isHistogramVisible: - self.update_histogram() self.update_status_message("Crop applied", timeout=5000) log.info("Crop applied to live session for %s", filepath)