From fc90b1e29bed11fc51c20fa1d00559e00ccf5a6d Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 11 Apr 2026 10:18:06 -0700 Subject: [PATCH 1/6] Minor sparkline fix --- faststack/qml/ThumbnailTile.qml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/faststack/qml/ThumbnailTile.qml b/faststack/qml/ThumbnailTile.qml index 78ac522..ae70398 100644 --- a/faststack/qml/ThumbnailTile.qml +++ b/faststack/qml/ThumbnailTile.qml @@ -107,8 +107,8 @@ Item { Image { id: thumbnailImage anchors.centerIn: parent - width: tile.tileIsFolder ? 120 : Math.min(thumbnailSize, parent.width) - height: tile.tileIsFolder ? 120 : Math.min(thumbnailSize, parent.height) + width: tile.tileIsFolder ? Math.min(120, parent.width) : Math.min(thumbnailSize, parent.width) + height: tile.tileIsFolder ? Math.min(120, parent.height) : Math.min(thumbnailSize, parent.height) fillMode: Image.PreserveAspectFit source: tile.tileThumbnailSource asynchronous: true @@ -463,7 +463,7 @@ Item { spacing: 1 // Upload bar (green) - top Rectangle { - width: 4 + width: 3 height: 3 radius: 0.5 color: tile.counterUploadedCol @@ -471,7 +471,7 @@ Item { } // Stack bar (orange) - middle Rectangle { - width: 4 + width: 3 height: 3 radius: 0.5 color: tile.counterStackedCol @@ -479,7 +479,7 @@ Item { } // Todo bar (red) - bottom Rectangle { - width: 4 + width: 3 height: 3 radius: 0.5 color: "#FF5252" // Brighter red From 9ef5b22dd90ce57d4cef9733e31d60f120eec16c Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 11 Apr 2026 20:39:45 -0700 Subject: [PATCH 2/6] fix bugs, improve sparkline --- faststack/app.py | 61 ++++++++++++------- faststack/io/sidecar.py | 15 +++++ faststack/qml/ThumbnailTile.qml | 77 +++++++++++++++++------- faststack/thumbnail_view/folder_stats.py | 45 ++++++++++---- 4 files changed, 142 insertions(+), 56 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 7d7d21b..c707d08 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -2021,39 +2021,39 @@ def _on_save_finished(self, save_result: dict): if save_result.get("started_from_restore_override"): self._clear_variant_override() - # 2. Update variants and re-select index - # Refresh list to pick up new backup files and update variant map - self.refresh_image_list() + # Record current path to stay on it after refresh (since index may shift) + preserved_path = None + if 0 <= self.current_index < len(self.image_files): + preserved_path = self.image_files[self.current_index].path - # Find and re-select the saved image - new_index = self.current_index + # 1. Update sidecar metadata FIRST so all following refreshes see it + if saved_path: + self.sidecar.update_metadata(saved_path, {"edited": True}) - if saved_path: - target_key = self._key(saved_path) - for i, img in enumerate(self.image_files): - if self._key(img.path) == target_key: - new_index = i - break - - self.current_index = new_index - - # Force UI Sync / Prefetch + # 2. Update variants and re-select index + self.refresh_image_list() + + if still_on_same_session: + # Still viewing the saved image — pin index and force sync + self._reindex_after_save(saved_path) self.image_cache.clear() self.prefetcher.cancel_all() self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() else: - # User navigated away — clear stale cache entry + # User navigated away — re-find new index for preserved_path and drop stale cache + if preserved_path: + self._reindex_after_save(preserved_path) if saved_path: self.image_cache.pop_path(saved_path) - # Always emit badge update — backup file was created if self.ui_state: self.ui_state.variantBadgesChanged.emit() self.update_status_message("Image saved") else: - self.update_status_message("Failed to save image") + # Success reported but result shape unexpected + log.warning("Save finished with unexpected result shape: %r", result) # --- Actions --- @@ -7373,6 +7373,9 @@ def execute_crop(self): ("crop", (str(saved_path), str(backup_path)), timestamp) ) + # Mark as edited in sidecar + self.sidecar.update_metadata(saved_path, {"edited": True}) + # Exit crop mode self.ui_state.isCropping = False self.ui_state.currentCropBox = (0, 0, 1000, 1000) @@ -7615,6 +7618,12 @@ def quick_auto_levels(self): ("auto_levels", (saved_path, backup_path), timestamp) ) + # 1. Update sidecar metadata FIRST so all following refreshes see it + self.sidecar.update_metadata(saved_path, {"edited": True}) + + # 2. Update list and model to pick up changes + self.refresh_image_list() + # Force reload to ensure disk consistency self.image_editor.clear() @@ -7704,6 +7713,10 @@ def _apply_auto_levels_at_index(self, index: int) -> bool: if save_result: saved_path, backup_path = save_result timestamp = time.time() + + # Mark as edited in sidecar + self.sidecar.update_metadata(saved_path, {"edited": True}) + self.undo_history.append( ("auto_levels", (saved_path, backup_path), timestamp) ) @@ -7849,8 +7862,16 @@ def quick_auto_white_balance(self): if save_result: saved_path, backup_path = save_result - # Track this action for undo timestamp = time.time() + # 1. Update sidecar metadata FIRST so all following refreshes see it + self.sidecar.update_metadata(saved_path, {"edited": True}) + + # 2. Update list and model to pick up changes + self.refresh_image_list() + + # Re-derive current_index + self._reindex_after_save(saved_path) + self.undo_history.append( ("auto_white_balance", (saved_path, backup_path), timestamp) ) @@ -7858,8 +7879,6 @@ def quick_auto_white_balance(self): # Force the image editor to clear its current state so it reloads fresh self.image_editor.clear() - # Re-derive current_index (backup is excluded from visible list) - self._reindex_after_save(saved_path) t_list = time.perf_counter() # Invalidate cache for the edited image so it's reloaded from disk diff --git a/faststack/io/sidecar.py b/faststack/io/sidecar.py index 251ceff..cb2fb92 100644 --- a/faststack/io/sidecar.py +++ b/faststack/io/sidecar.py @@ -270,3 +270,18 @@ def _stable_key_from_key(self, key: str, check_fs: bool = False) -> str: def set_last_index(self, index: int): self.data.last_index = index + + def update_metadata(self, image_ref: Union[str, Path], updates: dict): + """Update multiple metadata fields for an image and save if changed.""" + meta = self.get_metadata(image_ref, create=True) + changed = False + for key, value in updates.items(): + if hasattr(meta, key): + if getattr(meta, key) != value: + setattr(meta, key, value) + changed = True + else: + log.warning(f"Unknown metadata key: {key}") + + if changed: + self.save() diff --git a/faststack/qml/ThumbnailTile.qml b/faststack/qml/ThumbnailTile.qml index ae70398..37638d4 100644 --- a/faststack/qml/ThumbnailTile.qml +++ b/faststack/qml/ThumbnailTile.qml @@ -73,6 +73,9 @@ Item { } else if (tileMouseArea.containsMouse) { return hoverColor } + if (tile.tileIsFolder) { + return tile.isDarkTheme ? "#181818" : "#f0f0f0" + } return backgroundColor } radius: 4 @@ -107,10 +110,11 @@ Item { Image { id: thumbnailImage anchors.centerIn: parent - width: tile.tileIsFolder ? Math.min(120, parent.width) : Math.min(thumbnailSize, parent.width) - height: tile.tileIsFolder ? Math.min(120, parent.height) : Math.min(thumbnailSize, parent.height) + visible: !tile.tileIsFolder + width: Math.min(thumbnailSize, parent.width) + height: Math.min(thumbnailSize, parent.height) fillMode: Image.PreserveAspectFit - source: tile.tileThumbnailSource + source: tile.tileIsFolder ? "" : tile.tileThumbnailSource asynchronous: true cache: false smooth: true @@ -447,43 +451,72 @@ Item { property string numFont: "Consolas, Monaco, monospace" property int numSize: 11 - // Coverage sparkline (triple-channel: upload green, stack orange, todo red) - Row { - id: sparklineRow + // Coverage sparkline (4 separate lanes: green, yellow, orange, blue) + Column { + id: sparklineStack anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: countsRow.top anchors.bottomMargin: 4 - spacing: 1 + spacing: 0 // No gap between lanes visible: tile.tileFolderStats && tile.tileFolderStats.coverage_buckets && tile.tileFolderStats.coverage_buckets.length > 0 - Repeater { - model: tile.tileFolderStats && tile.tileFolderStats.coverage_buckets ? tile.tileFolderStats.coverage_buckets : [] - - delegate: Column { - spacing: 1 - // Upload bar (green) - top - Rectangle { + // Lane 1: Uploaded (Green) + Row { + spacing: 1 + Repeater { + model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] + delegate: Rectangle { width: 3 height: 3 radius: 0.5 color: tile.counterUploadedCol - opacity: modelData[0] * 0.7 + 0.3 // 0.3 base opacity, up to 1.0 + // Use non-zero threshold to jump-start visibility + opacity: modelData[0] > 0 ? Math.max(0.5, modelData[0]) : 0 + } + } + } + + // Lane 2: Edited (Yellow) + Row { + spacing: 1 + Repeater { + model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] + delegate: Rectangle { + width: 3 + height: 3 + radius: 0.5 + color: tile.counterEditedCol + opacity: modelData[1] > 0 ? Math.max(0.5, modelData[1]) : 0 } - // Stack bar (orange) - middle - Rectangle { + } + } + + // Lane 3: Stacked (Orange) + Row { + spacing: 1 + Repeater { + model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] + delegate: Rectangle { width: 3 height: 3 radius: 0.5 color: tile.counterStackedCol - opacity: modelData[1] * 0.7 + 0.3 + opacity: modelData[2] > 0 ? Math.max(0.5, modelData[2]) : 0 } - // Todo bar (red) - bottom - Rectangle { + } + } + + // Lane 4: Todo (Blue) + Row { + spacing: 1 + Repeater { + model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] + delegate: Rectangle { width: 3 height: 3 radius: 0.5 - color: "#FF5252" // Brighter red - opacity: modelData[2] * 0.7 + 0.3 + color: tile.todoColor + opacity: modelData[3] > 0 ? Math.max(0.5, modelData[3]) : 0 } } } diff --git a/faststack/thumbnail_view/folder_stats.py b/faststack/thumbnail_view/folder_stats.py index de19a7e..8f27bca 100644 --- a/faststack/thumbnail_view/folder_stats.py +++ b/faststack/thumbnail_view/folder_stats.py @@ -25,10 +25,10 @@ class FolderStats: # Named 'jpg_count' for historical reasons; displayed as "IMG" in UI jpg_count: int = 0 raw_count: int = 0 - # Coverage sparkline data: list of (upload_ratio, stack_ratio, todo_ratio) tuples per bucket + # Coverage sparkline data: list of (upload_ratio, edited_ratio, stack_ratio, todo_ratio) tuples per bucket # Each ratio is 0.0-1.0, representing the fraction of JPGs in that bucket # that have the flag set. Empty list if no faststack.json or no JPGs. - coverage_buckets: list[tuple[float, float, float]] = field(default_factory=list) + coverage_buckets: list[tuple[float, float, float, float]] = field(default_factory=list) # Cache by (folder_path, json_mtime_ns, folder_mtime_ns) to avoid re-parsing during scroll @@ -216,9 +216,9 @@ def _parse_faststack_json(json_path: Path) -> Optional[FolderStats]: def _compute_coverage_buckets( jpg_files: list, entries: Dict[str, dict], num_buckets: int = 40 ) -> list: - """Compute coverage sparkline buckets for uploads, stacks, and todos. + """Compute coverage sparkline buckets for uploads, edits, stacks, and todos. - Returns a list of (upload_ratio, stack_ratio, todo_ratio) tuples, one per bucket. + Returns a list of (upload_ratio, edited_ratio, stack_ratio, todo_ratio) tuples. Each ratio is 0.0-1.0, representing the fraction of JPGs in that bucket with the respective flag set. @@ -228,7 +228,7 @@ def _compute_coverage_buckets( num_buckets: Number of buckets to divide files into (default 40) Returns: - List of (upload_ratio, stack_ratio, todo_ratio) tuples, or empty list if no JPGs. + List of (upload_ratio, edited_ratio, stack_ratio, todo_ratio) tuples. """ if not jpg_files: return [] @@ -238,8 +238,11 @@ def _compute_coverage_buckets( num_buckets = total_files # Single-pass accumulation into buckets to avoid redundant list processing - # Each entry is [uploaded_count, stacked_count, todo_count, total_in_bucket] - accumulators = [[0, 0, 0, 0] for _ in range(num_buckets)] + # Each entry is [uploaded_count, edited_count, stacked_count, todo_count, total_in_bucket] + accumulators = [[0, 0, 0, 0, 0] for _ in range(num_buckets)] + + # Lazy dictionary for case-insensitive lookup (only built if direct matching fails) + entries_lower = None for i, filename in enumerate(jpg_files): # Map file index to bucket index using floor division @@ -247,27 +250,43 @@ def _compute_coverage_buckets( # Efficient stem extraction and metadata lookup stem, _ = os.path.splitext(filename) + + # Priority: 1. Exact filename, 2. Stem, 3. Case-insensitive filename, 4. Case-insensitive stem meta = entries.get(filename) if meta is None: meta = entries.get(stem) + + if meta is None and entries: + if entries_lower is None: + entries_lower = {k.lower(): v for k, v in entries.items() if isinstance(k, str)} + meta = entries_lower.get(filename.lower()) + if meta is None: + meta = entries_lower.get(stem.lower()) if isinstance(meta, dict): if meta.get("uploaded", False): accumulators[bucket_idx][0] += 1 - if meta.get("stacked", False): + if meta.get("edited", False): accumulators[bucket_idx][1] += 1 - if meta.get("todo", False): + if meta.get("stacked", False): accumulators[bucket_idx][2] += 1 + if meta.get("todo", False): + accumulators[bucket_idx][3] += 1 - accumulators[bucket_idx][3] += 1 + accumulators[bucket_idx][4] += 1 # Convert counts to ratios buckets = [] - for uploaded, stacked, todo, count in accumulators: + for uploaded, edited, stacked, todo, count in accumulators: if count == 0: - buckets.append((0.0, 0.0, 0.0)) + buckets.append((0.0, 0.0, 0.0, 0.0)) else: - buckets.append((uploaded / count, stacked / count, todo / count)) + buckets.append(( + uploaded / count, + edited / count, + stacked / count, + todo / count + )) return buckets From 4c2f57e8ddb8023b5fe455cf80589c67cbd4c0ad Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 11 Apr 2026 20:40:06 -0700 Subject: [PATCH 3/6] format with black --- faststack/app.py | 2 +- faststack/thumbnail_view/folder_stats.py | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index c707d08..fd6ad4f 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -2032,7 +2032,7 @@ def _on_save_finished(self, save_result: dict): # 2. Update variants and re-select index self.refresh_image_list() - + if still_on_same_session: # Still viewing the saved image — pin index and force sync self._reindex_after_save(saved_path) diff --git a/faststack/thumbnail_view/folder_stats.py b/faststack/thumbnail_view/folder_stats.py index 8f27bca..77c1451 100644 --- a/faststack/thumbnail_view/folder_stats.py +++ b/faststack/thumbnail_view/folder_stats.py @@ -28,7 +28,9 @@ class FolderStats: # Coverage sparkline data: list of (upload_ratio, edited_ratio, stack_ratio, todo_ratio) tuples per bucket # Each ratio is 0.0-1.0, representing the fraction of JPGs in that bucket # that have the flag set. Empty list if no faststack.json or no JPGs. - coverage_buckets: list[tuple[float, float, float, float]] = field(default_factory=list) + coverage_buckets: list[tuple[float, float, float, float]] = field( + default_factory=list + ) # Cache by (folder_path, json_mtime_ns, folder_mtime_ns) to avoid re-parsing during scroll @@ -250,15 +252,17 @@ def _compute_coverage_buckets( # Efficient stem extraction and metadata lookup stem, _ = os.path.splitext(filename) - + # Priority: 1. Exact filename, 2. Stem, 3. Case-insensitive filename, 4. Case-insensitive stem meta = entries.get(filename) if meta is None: meta = entries.get(stem) - + if meta is None and entries: if entries_lower is None: - entries_lower = {k.lower(): v for k, v in entries.items() if isinstance(k, str)} + entries_lower = { + k.lower(): v for k, v in entries.items() if isinstance(k, str) + } meta = entries_lower.get(filename.lower()) if meta is None: meta = entries_lower.get(stem.lower()) @@ -281,12 +285,9 @@ def _compute_coverage_buckets( if count == 0: buckets.append((0.0, 0.0, 0.0, 0.0)) else: - buckets.append(( - uploaded / count, - edited / count, - stacked / count, - todo / count - )) + buckets.append( + (uploaded / count, edited / count, stacked / count, todo / count) + ) return buckets From 87fdb519da7c94aea4c2bde5ba48a1c8d410be7b Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 12 Apr 2026 00:02:52 -0700 Subject: [PATCH 4/6] Fix a bug in the image cropping --- .codex | 0 faststack/qml/Components.qml | 101 +++++++++++++++++++++++++++++------ 2 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 0b413a6..a96bd62 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -15,6 +15,16 @@ Item { // Expose zoom state to parent (Main.qml title bar) readonly property real currentZoomScale: imageRotator.zoomScale readonly property real currentFitScale: imageRotator.fitScale + // Freeze source swaps during crop drags so async preview refreshes + // cannot change the visual scale in the middle of the gesture. + readonly property string requestedImageSource: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" + property string cropDragImageSource: "" + readonly property bool isCropSourceFrozen: cropDragImageSource !== "" && ((mainMouseArea && mainMouseArea.isCropDragging) || (uiState && uiState.isCropping)) + readonly property string displayedImageSource: isCropSourceFrozen ? cropDragImageSource : requestedImageSource + + function rememberCropDragImageSource() { + cropDragImageSource = mainImage && mainImage.source ? mainImage.source : requestedImageSource + } Connections { target: uiState @@ -32,8 +42,17 @@ Item { if (uiState.isZoomed) uiState.setZoomed(false) } } + function onIsCroppingChanged() { + if (uiState && uiState.isCropping) { + if (loupeView.cropDragImageSource === "") { + loupeView.rememberCropDragImageSource() + } + } else { + loupeView.cropDragImageSource = "" + } + } } - + Keys.onEscapePressed: (event) => { if (uiState && uiState.isCropping) { if (mainMouseArea.isRotating) { @@ -148,7 +167,18 @@ Item { // Fix C: Persist requested absolute zoom across source changes property real targetAbsoluteZoom: -1.0 + // Zoom/pan lock: when >= 0, any change to zoomScale is reverted. + // Active only during crop drag to keep the coordinate system stable. + property real _lockedZoom: -1 + property real _lockedPanX: -1e9 + property real _lockedPanY: -1e9 + onZoomScaleChanged: { + // During crop drag, revert any zoom changes to keep coordinates stable + if (_lockedZoom >= 0 && Math.abs(zoomScale - _lockedZoom) > 0.0001) { + zoomScale = _lockedZoom + return + } mainImage.updateZoomState() if (cropOverlay.visible) cropOverlay.updateCropRect() } @@ -255,10 +285,18 @@ Item { Translate { id: panTransform onXChanged: { + if (imageRotator._lockedPanX > -1e8 && Math.abs(x - imageRotator._lockedPanX) > 0.01) { + x = imageRotator._lockedPanX + return + } mainImage.updateHistogramWithZoom() if (cropOverlay.visible) cropOverlay.updateCropRect() } onYChanged: { + if (imageRotator._lockedPanY > -1e8 && Math.abs(y - imageRotator._lockedPanY) > 0.01) { + y = imageRotator._lockedPanY + return + } mainImage.updateHistogramWithZoom() if (cropOverlay.visible) cropOverlay.updateCropRect() } @@ -374,7 +412,7 @@ Item { } } - source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" + source: loupeView.displayedImageSource function _currentDpr() { // Per-window DPR is the safest (multi-monitor setups) @@ -386,6 +424,12 @@ Item { function handleSourceSizeChange() { if (mainImage.sourceSize.width <= 0 || mainImage.sourceSize.height <= 0) return + if (mainMouseArea.isCropDragging) { + // Mark stale so onReleased will pick up the new geometry + _sourceSizeStale = true + return + } + const dpr = _currentDpr() // Treat baseW/baseH as *device-independent pixels* that correspond to 1:1 physical pixels at zoomScale=1 @@ -411,7 +455,14 @@ Item { // Force reset when source changes (existing logic) onSourceChanged: { - // Reset base size for new image so we pick up the new sourceSize + if (mainMouseArea.isCropDragging) { + // Source changed mid-drag (e.g. high-res edit buffer loading). + // Defer ALL visual/geometry resets so the coordinate system + // stays stable and the image doesn't flash black. + mainImage._sourceSizeStale = true + return + } + imageRotator.baseW = 0 imageRotator.baseH = 0 @@ -433,6 +484,7 @@ Item { smooth: false // Crisp rendering for technical accuracy mipmap: false // Crisp rendering + property bool _sourceSizeStale: false property bool isZooming: false // IMPORTANT: tell Python the *viewport* size, not the sourceSize size @@ -612,6 +664,14 @@ Item { } if (mouse.button === Qt.RightButton) { + // Activate drag guard BEFORE toggle_crop_mode so that any + // source/geometry changes it triggers are properly deferred. + loupeView.rememberCropDragImageSource() + isCropDragging = true + imageRotator._lockedZoom = imageRotator.zoomScale + imageRotator._lockedPanX = panTransform.x + imageRotator._lockedPanY = panTransform.y + if (!uiState.isCropping && controller) { controller.toggle_crop_mode() // Ensure mode is ON } @@ -620,7 +680,6 @@ Item { loupeView.forceActiveFocus() // Start a NEW crop rectangle immediately from the clicked point - // This fulfills the "right-click drag crops immediately" requirement var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) var mx = coords.x * 1000 var my = coords.y * 1000 @@ -634,7 +693,6 @@ Item { cropBoxStartBottom = my uiState.currentCropBox = [Math.round(mx), Math.round(my), Math.round(mx), Math.round(my)] - isCropDragging = true return } @@ -657,7 +715,8 @@ Item { // This ensures handles remain usable at all zoom levels var edgeThreshold = Math.max(5, Math.min(40, Math.max(threshX, threshY))) - var inside = mx >= box[0] && mx <= box[2] && my >= box[1] && my <= box[3] + // Make it so the user doesn't have to click exactly on the crop box to modify it + var inside = mx >= (box[0] - edgeThreshold) && mx <= (box[2] + edgeThreshold) && my >= (box[1] - edgeThreshold) && my <= (box[3] + edgeThreshold) if (mainMouseArea.isRotating && cropOverlay.visible && rotateKnob.visible) { // knob center in mainMouseArea coords (includes cropRect rotation) @@ -700,6 +759,7 @@ Item { cropBoxStartBottom = box[3] } + loupeView.rememberCropDragImageSource() isCropDragging = true return } @@ -754,19 +814,19 @@ Item { uiState.currentCropBox = [Math.round(mx), Math.round(my), Math.round(mx), Math.round(my)] } + loupeView.rememberCropDragImageSource() isCropDragging = true } } // Legacy getCropRect removed - using Image Space hit testing instead. // mapToImageCoordinates maps directly to mainImage function mapToImageCoordinates(screenPoint) { - if (!mainImage || mainImage.width <= 0) return {x:0, y:0} - - // Simplified: Use Qt-native mapping to handle scale, pan, and rotation + if (!mainImage) return {x:0, y:0} + var w = mainImage.width > 0 ? mainImage.width : mainImage.sourceSize.width + var h = mainImage.height > 0 ? mainImage.height : mainImage.sourceSize.height + if (w <= 0 || h <= 0) return {x:0, y:0} var p = mainImage.mapFromItem(mainMouseArea, screenPoint.x, screenPoint.y) - - // Normalize (0-1) - return { x: p.x / mainImage.width, y: p.y / mainImage.height } + return { x: p.x / w, y: p.y / h } } onPositionChanged: function(mouse) { // Darken painting drag — clamp to image bounds @@ -891,13 +951,22 @@ Item { isDraggingOutside = false if (uiState && uiState.isCropping && isCropDragging) { - isCropDragging = false cropDragMode = "none" - // Settle zoom/pan after rotation ends (Force recompute) + + // Release zoom/pan lock BEFORE flushing deferred updates + imageRotator._lockedZoom = -1 + imageRotator._lockedPanX = -1e9 + imageRotator._lockedPanY = -1e9 + if (mainMouseArea.isRotating) imageRotator.recomputeFitScale(true) - // Ensure loupeView has active focus so Escape key works loupeView.forceActiveFocus() + + // Flush deferred source-size update now that drag is over + if (mainImage._sourceSizeStale) { + mainImage._sourceSizeStale = false + mainImage.handleSourceSizeChange() + } } } @@ -964,7 +1033,7 @@ Item { } function updateCropBox(x1, y1, x2, y2, applyAspectRatio = false) { - if (!uiState || !mainImage.source || mainImage.width <= 0) return + if (!uiState || !mainImage.source) return var imgCoord1 = mapToImageCoordinates(Qt.point(x1, y1)) var imgCoord2 = mapToImageCoordinates(Qt.point(x2, y2)) From 1f30ecc30499145225d0ab7cf60b63fd388c1ed6 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 12 Apr 2026 00:12:26 -0700 Subject: [PATCH 5/6] Clean up logic in Components.qml --- .gitignore | 1 + faststack/qml/Components.qml | 152 +++++++++++++++++------------------ 2 files changed, 75 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index 975488b..42b8bd9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ ANTIGRAVITY_RULES.md .claude/ .agent/ tools/ +.codex/ # We don't have any good docs yet docs/ diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index a96bd62..643c101 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -22,8 +22,14 @@ Item { readonly property bool isCropSourceFrozen: cropDragImageSource !== "" && ((mainMouseArea && mainMouseArea.isCropDragging) || (uiState && uiState.isCropping)) readonly property string displayedImageSource: isCropSourceFrozen ? cropDragImageSource : requestedImageSource - function rememberCropDragImageSource() { - cropDragImageSource = mainImage && mainImage.source ? mainImage.source : requestedImageSource + function freezeCropImageSource() { + if (cropDragImageSource === "") { + cropDragImageSource = mainImage && mainImage.source ? mainImage.source : requestedImageSource + } + } + + function releaseCropImageSource() { + cropDragImageSource = "" } Connections { @@ -44,11 +50,9 @@ Item { } function onIsCroppingChanged() { if (uiState && uiState.isCropping) { - if (loupeView.cropDragImageSource === "") { - loupeView.rememberCropDragImageSource() - } + loupeView.freezeCropImageSource() } else { - loupeView.cropDragImageSource = "" + loupeView.releaseCropImageSource() } } } @@ -59,12 +63,12 @@ Item { // Revert rotation mainMouseArea.cropRotation = mainMouseArea.cropStartRotation if (controller) controller.set_straighten_angle(mainMouseArea.cropRotation, -1) - + + mainMouseArea.endCropInteraction() mainMouseArea.isRotating = false - mainMouseArea.cropDragMode = "none" - mainMouseArea.isCropDragging = false event.accepted = true } else if (controller) { + mainMouseArea.endCropInteraction() controller.cancel_crop_mode() mainMouseArea.cropRotation = 0 // Reset local rotation event.accepted = true @@ -72,9 +76,6 @@ Item { } } - - - Keys.onPressed: (event) => { // Zoom Shortcuts (Ctrl+1..4) if (event.modifiers & Qt.ControlModifier) { @@ -642,6 +643,51 @@ Item { } } + function setCropBoxStart(left, top, right, bottom) { + cropBoxStartLeft = left + cropBoxStartTop = top + cropBoxStartRight = right + cropBoxStartBottom = bottom + } + + function setCropBoxStartFromBox(box) { + if (!box || box.length !== 4) return + setCropBoxStart(box[0], box[1], box[2], box[3]) + } + + function beginNewCrop(mouseX, mouseY, mx, my) { + cropDragMode = "new" + cropStartX = mouseX + cropStartY = mouseY + setCropBoxStart(mx, my, mx, my) + uiState.currentCropBox = [Math.round(mx), Math.round(my), Math.round(mx), Math.round(my)] + } + + function beginCropInteraction() { + loupeView.freezeCropImageSource() + isCropDragging = true + imageRotator._lockedZoom = imageRotator.zoomScale + imageRotator._lockedPanX = panTransform.x + imageRotator._lockedPanY = panTransform.y + } + + function endCropInteraction() { + isCropDragging = false + cropDragMode = "none" + + imageRotator._lockedZoom = -1 + imageRotator._lockedPanX = -1e9 + imageRotator._lockedPanY = -1e9 + + if (mainMouseArea.isRotating) imageRotator.recomputeFitScale(true) + loupeView.forceActiveFocus() + + if (mainImage._sourceSizeStale) { + mainImage._sourceSizeStale = false + mainImage.handleSourceSizeChange() + } + } + onPressed: function(mouse) { lastX = mouse.x lastY = mouse.y @@ -666,15 +712,16 @@ Item { if (mouse.button === Qt.RightButton) { // Activate drag guard BEFORE toggle_crop_mode so that any // source/geometry changes it triggers are properly deferred. - loupeView.rememberCropDragImageSource() - isCropDragging = true - imageRotator._lockedZoom = imageRotator.zoomScale - imageRotator._lockedPanX = panTransform.x - imageRotator._lockedPanY = panTransform.y + beginCropInteraction() if (!uiState.isCropping && controller) { controller.toggle_crop_mode() // Ensure mode is ON } + + if (!uiState || !uiState.isCropping) { + endCropInteraction() + return + } // Ensure loupeView has active focus so Escape key works loupeView.forceActiveFocus() @@ -683,16 +730,8 @@ Item { var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) var mx = coords.x * 1000 var my = coords.y * 1000 - - cropDragMode = "new" - cropStartX = mouse.x - cropStartY = mouse.y - cropBoxStartLeft = mx - cropBoxStartTop = my - cropBoxStartRight = mx - cropBoxStartBottom = my - - uiState.currentCropBox = [Math.round(mx), Math.round(my), Math.round(mx), Math.round(my)] + + beginNewCrop(mouse.x, mouse.y, mx, my) return } @@ -753,14 +792,10 @@ Item { // Seed cropBoxStart variables if (box && box.length === 4) { - cropBoxStartLeft = box[0] - cropBoxStartTop = box[1] - cropBoxStartRight = box[2] - cropBoxStartBottom = box[3] + setCropBoxStartFromBox(box) } - loupeView.rememberCropDragImageSource() - isCropDragging = true + beginCropInteraction() return } } @@ -768,16 +803,7 @@ Item { // If crop box is full image, always start a new crop else if (isFullImage) { // Start a new crop rectangle from the clicked point - cropDragMode = "new" - cropStartX = mouse.x - cropStartY = mouse.y - - cropBoxStartLeft = mx - cropBoxStartTop = my - cropBoxStartRight = mx - cropBoxStartBottom = my - - uiState.currentCropBox = [Math.round(mx), Math.round(my), Math.round(mx), Math.round(my)] + beginNewCrop(mouse.x, mouse.y, mx, my) } else if (inside) { // Determine which edge/corner is being dragged (Image Space) var nearLeft = Math.abs(mx - box[0]) < edgeThreshold @@ -794,28 +820,13 @@ Item { else if (nearTop) cropDragMode = "top" else if (nearBottom) cropDragMode = "bottom" else cropDragMode = "move" - - // Store initial crop box - cropBoxStartLeft = box[0] - cropBoxStartTop = box[1] - cropBoxStartRight = box[2] - cropBoxStartBottom = box[3] + + setCropBoxStartFromBox(box) } else { // Start new crop rectangle - cropDragMode = "new" - cropStartX = mouse.x - cropStartY = mouse.y - - // Initialize anchors - cropBoxStartLeft = mx - cropBoxStartRight = mx - cropBoxStartTop = my - cropBoxStartBottom = my - - uiState.currentCropBox = [Math.round(mx), Math.round(my), Math.round(mx), Math.round(my)] + beginNewCrop(mouse.x, mouse.y, mx, my) } - loupeView.rememberCropDragImageSource() - isCropDragging = true + beginCropInteraction() } } // Legacy getCropRect removed - using Image Space hit testing instead. @@ -951,22 +962,7 @@ Item { isDraggingOutside = false if (uiState && uiState.isCropping && isCropDragging) { - isCropDragging = false - cropDragMode = "none" - - // Release zoom/pan lock BEFORE flushing deferred updates - imageRotator._lockedZoom = -1 - imageRotator._lockedPanX = -1e9 - imageRotator._lockedPanY = -1e9 - - if (mainMouseArea.isRotating) imageRotator.recomputeFitScale(true) - loupeView.forceActiveFocus() - - // Flush deferred source-size update now that drag is over - if (mainImage._sourceSizeStale) { - mainImage._sourceSizeStale = false - mainImage.handleSourceSizeChange() - } + endCropInteraction() } } From ee7171f682e7a5619cec79c3d49927db91ff7d67 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 12 Apr 2026 00:16:40 -0700 Subject: [PATCH 6/6] clean files --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 42b8bd9..975488b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ ANTIGRAVITY_RULES.md .claude/ .agent/ tools/ -.codex/ # We don't have any good docs yet docs/