diff --git a/faststack/app.py b/faststack/app.py index 7b2f109..64763b8 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -512,6 +512,7 @@ def __init__( # -- Stacking State -- self.stack_start_index: Optional[int] = None + self.stack_end_index: Optional[int] = None self.stacks: List[List[int]] = [] # -- Batch Selection State (for drag-and-drop) -- @@ -896,6 +897,13 @@ def set_sort_mode(self, mode: str): and 0 <= self.stack_start_index < len(self.image_files) else None ) + old_stack_end_path = ( + self.image_files[self.stack_end_index].path + if not clear_stacks + and self.stack_end_index is not None + and 0 <= self.stack_end_index < len(self.image_files) + else None + ) old_batch_start_path = ( self.image_files[self.batch_start_index].path if self.batch_start_index is not None @@ -917,6 +925,7 @@ def set_sort_mode(self, mode: str): if clear_stacks: self.stacks = [] self.stack_start_index = None + self.stack_end_index = None self.sidecar.data.stacks = [] self.sidecar.save() elif have_stacks: @@ -931,6 +940,9 @@ def set_sort_mode(self, mode: str): if not clear_stacks and old_stack_start_path: key = self._key(old_stack_start_path) self.stack_start_index = self._path_to_index.get(key) + if not clear_stacks and old_stack_end_path: + key = self._key(old_stack_end_path) + self.stack_end_index = self._path_to_index.get(key) if self.image_files and preserved_path: target_key = self._key(preserved_path) @@ -4429,8 +4441,13 @@ def jump_to_last_uploaded(self): # for idx in range(last_index, -1, -1) for idx in range(len(self.image_files) - 1, -1, -1): img = self.image_files[idx] - # Dynamic look-up of self.sidecar as requested (important for mocks in tests) - meta = self.sidecar.get_metadata(img.path, create=False) + # This is a read-only sweep across the folder. Skipping migration + # avoids an O(images x sidecar entries) filesystem scan on misses. + meta = self.sidecar.get_metadata( + img.path, + create=False, + migrate=False, + ) uploaded = meta.uploaded if meta else False @@ -4763,6 +4780,8 @@ def duplicate_current_image(self): and self.stack_start_index >= insert_index ): self.stack_start_index += 1 + if self.stack_end_index is not None and self.stack_end_index >= insert_index: + self.stack_end_index += 1 if stacks_changed: self.sidecar.data.stacks = self.stacks self.sidecar.save() @@ -5286,32 +5305,48 @@ def _on_exif_brief_ready(self, exif_key: tuple[str, str], brief: str): if self.ui_state: self.ui_state.metadataChanged.emit() + def _define_pending_stack(self) -> bool: + if self.stack_start_index is None or self.stack_end_index is None: + return False + + start = min(self.stack_start_index, self.stack_end_index) + end = max(self.stack_start_index, self.stack_end_index) + self.stacks.append([start, end]) + self.stacks.sort() # Keep stacks sorted by start index + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + log.info("Defined new stack: [%d, %d]", start, end) + self.stack_start_index = None + self.stack_end_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Notify QML of data change + self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog + self.sync_ui_state() + count = end - start + 1 + self.update_status_message( + f"Stack defined: {count} image{'' if count == 1 else 's'}" + ) + return True + def begin_new_stack(self): self.stack_start_index = self.current_index log.info("Stack start marked at index %d", self.stack_start_index) + if self._define_pending_stack(): + return self._metadata_cache_index = (-1, -1) # Invalidate cache self.dataChanged.emit() # Update UI to show start marker self.sync_ui_state() + self.update_status_message("Stack start marked") def end_current_stack(self): - log.info( - "end_current_stack called. stack_start_index: %s", self.stack_start_index - ) - if self.stack_start_index is not None: - start = min(self.stack_start_index, self.current_index) - end = max(self.stack_start_index, self.current_index) - self.stacks.append([start, end]) - self.stacks.sort() # Keep stacks sorted by start index - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - log.info("Defined new stack: [%d, %d]", start, end) - self.stack_start_index = None - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() # Notify QML of data change - self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog - self.sync_ui_state() - else: - log.warning("No stack start marked. Press '[' first.") + self.stack_end_index = self.current_index + log.info("Stack end marked at index %d", self.stack_end_index) + if self._define_pending_stack(): + return + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Update UI to show end marker + self.sync_ui_state() + self.update_status_message("Stack end marked") def begin_new_batch(self): """Mark the start of a new batch for drag-and-drop.""" @@ -6091,6 +6126,7 @@ def clear_all_stacks(self): log.info("Clearing all defined stacks.") self.stacks = [] self.stack_start_index = None + self.stack_end_index = None # Do NOT clear batches here self.sidecar.data.stacks = self.stacks @@ -6996,6 +7032,7 @@ def _switch_to_directory( self.batches = [] self.batch_start_index = None self.stack_start_index = None + self.stack_end_index = None # Clear caches since they reference old directory's images with self._last_image_lock: @@ -7763,10 +7800,23 @@ def _shift_start_index( def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> None: """Restore items to the UI list in correct order.""" - # Insert in ascending index order so each insertion shifts subsequent - # indices correctly, restoring the original list positions. + # Each saved idx is an ORIGINAL list position. During a partial rollback + # some earlier-deleted items stay removed, so a raw insert at idx would + # overshoot a still-missing lower position. Shift each idx left by the + # count of still-missing lower positions to land it in the compressed + # list. Insert in ascending index order so prior inserts settle first. + present_keys = {self._key(x.path) for x in self.image_files} + restoring_keys = {self._key(img.path) for _, img in items} + still_missing = sorted( + idx + for idx, img in job.removed_items + if self._key(img.path) not in present_keys + and self._key(img.path) not in restoring_keys + ) for idx, img in sorted(items, key=lambda x: x[0]): - self.image_files.insert(min(idx, len(self.image_files)), img) + shift = sum(1 for m in still_missing if m < idx) + pos = min(max(idx - shift, 0), len(self.image_files)) + self.image_files.insert(pos, img) # Restore selection/focus (approximated) self.current_index = min(job.previous_index, len(self.image_files) - 1) @@ -7815,6 +7865,7 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No if restored == original: self.stacks = [s[:] for s in ui.saved_stacks] self.stack_start_index = ui.saved_stack_start_index + self.stack_end_index = ui.saved_stack_end_index else: still_deleted = sorted(original - restored) self.stacks = self._recompute_batches_after_deletions( @@ -7823,6 +7874,9 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No self.stack_start_index = self._shift_start_index( ui.saved_stack_start_index, still_deleted ) + self.stack_end_index = self._shift_start_index( + ui.saved_stack_end_index, still_deleted + ) self.sidecar.data.stacks = self.stacks self._metadata_cache_index = (-1, -1) @@ -7944,6 +7998,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: pre_batch_start_snapshot = self.batch_start_index pre_stack_snapshot = [s[:] for s in self.stacks] pre_stack_start_snapshot = self.stack_start_index + pre_stack_end_snapshot = self.stack_end_index # Shared helper: compute post-deletion index for a surviving index. deleted_ascending = sorted(validated_sorted) @@ -8017,6 +8072,11 @@ def _shift(orig_idx: int) -> int: self.stack_start_index = None else: self.stack_start_index = _shift(pre_stack_start_snapshot) + if pre_stack_end_snapshot is not None: + if pre_stack_end_snapshot in deleted_set: + self.stack_end_index = None + else: + self.stack_end_index = _shift(pre_stack_end_snapshot) # Update UI immediately - this is fast since it just reads from memory # Check for existence, not truthiness (empty cache is falsy) @@ -8100,6 +8160,7 @@ def _shift(orig_idx: int) -> int: saved_batch_start_index=pre_batch_start_snapshot, saved_stacks=pre_stack_snapshot, saved_stack_start_index=pre_stack_start_snapshot, + saved_stack_end_index=pre_stack_end_snapshot, ), ) @@ -8453,6 +8514,7 @@ def undo_delete(self): if ui is not None and ui.saved_stacks is not None and removed_items: self.stacks = ui.saved_stacks self.stack_start_index = ui.saved_stack_start_index + self.stack_end_index = ui.saved_stack_end_index self.sidecar.data.stacks = self.stacks self._metadata_cache_index = (-1, -1) self.sync_ui_state() @@ -12107,6 +12169,12 @@ def _get_stack_info(self, index: int) -> str: and self.stack_start_index == index ): info = "Stack Start Marked" + elif ( + not info + and self.stack_end_index is not None + and self.stack_end_index == index + ): + info = "Stack End Marked" log.debug("_get_stack_info for index %d: %s", index, info) return info diff --git a/faststack/deletion_types.py b/faststack/deletion_types.py index 7d0ddf8..b9f71b2 100644 --- a/faststack/deletion_types.py +++ b/faststack/deletion_types.py @@ -33,6 +33,7 @@ class UIStateRestoration: saved_batch_start_index: Optional[int] = None saved_stacks: Optional[list] = None saved_stack_start_index: Optional[int] = None + saved_stack_end_index: Optional[int] = None @dataclass diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 1f99782..86bded6 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -78,7 +78,7 @@ Item { mainMouseArea.clearPendingRotation(0) mainMouseArea.endCropInteraction() - mainMouseArea.cropRotation = 0 + mainMouseArea.resetCropRotation() mainMouseArea.isRotating = false loupeView.controllerRef.cancel_crop_mode() return true @@ -120,7 +120,7 @@ Item { mainMouseArea.clearPendingRotation(0) mainMouseArea.endCropInteraction() mainMouseArea.isRotating = false - mainMouseArea.cropRotation = 0 + mainMouseArea.resetCropRotation() } if (loupeView.uiStateRef) loupeView.uiStateRef.isCropRotating = false loupeView.releaseCropImageSource() @@ -256,13 +256,14 @@ Item { isUpdatingGeometry = true var rad = mainMouseArea.cropRotation * (Math.PI / 180.0) + var cropModeActive = loupeView.uiStateRef && loupeView.uiStateRef.isCropping // Use base size if available (stable during zoom), otherwise sourceSize var w = (baseW > 0) ? baseW : mainImage.sourceSize.width var h = (baseH > 0) ? baseH : mainImage.sourceSize.height - var newW = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad)) - var newH = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad)) + var newW = cropModeActive ? w : Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad)) + var newH = cropModeActive ? h : Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad)) width = newW height = newH @@ -272,7 +273,7 @@ Item { mainImage.height = h isUpdatingGeometry = false - recomputeFitScale() + if (!cropModeActive) recomputeFitScale() } Connections { @@ -374,7 +375,22 @@ Item { // width: sourceSize.width // height: sourceSize.height - rotation: mainMouseArea.cropRotation + function cropRotationOriginX() { + if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping) return mainMouseArea.cropRotationPivotX * mainImage.width + return mainImage.width / 2 + } + + function cropRotationOriginY() { + if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping) return mainMouseArea.cropRotationPivotY * mainImage.height + return mainImage.height / 2 + } + + transform: Rotation { + id: imageStraightenRotation + origin.x: mainImage.cropRotationOriginX() + origin.y: mainImage.cropRotationOriginY() + angle: mainMouseArea.cropRotation + } // Darken mask overlay - anchored to mainImage, rotates/scales with it Image { @@ -390,83 +406,6 @@ Item { opacity: 1.0 // Opacity is baked into the ARGB32 image } - // Crop overlay - anchored to mainImage to rotate with it - Item { - id: cropOverlay - property bool isFullImageCrop: { - var b = _liveCropBox() - return b && b.length === 4 && b[0]===0 && b[1]===0 && b[2]===1000 && b[3]===1000 - } - property bool hasPositiveCrop: { - var b = _liveCropBox() - return b && b.length === 4 && (b[2] - b[0]) > 0 && (b[3] - b[1]) > 0 - } - 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 { - target: loupeView.uiStateRef - function onCurrentCropBoxChanged() { - cropOverlay._cropBoxRev += 1 - } - } - - function _liveCropBox() { - var _ = cropOverlay._cropBoxRev - return loupeView.uiStateRef ? loupeView.uiStateRef.currentCropBox : null - } - - visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping - anchors.fill: parent // Fills mainImage - z: 100 - - // Dimmer Rectangles — only render when a real crop is active/being drawn - Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 } - Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 } - Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 } - Rectangle { visible: cropOverlay.hasDrawableCrop; x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 } - - Rectangle { - id: cropRect - x: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? (b[0] / 1000) * parent.width : 0 } - y: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? (b[1] / 1000) * parent.height : 0 } - width: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? ((b[2] - b[0]) / 1000) * parent.width : 0 } - height: { var b = cropOverlay._liveCropBox(); return (b && b.length === 4) ? ((b[3] - b[1]) / 1000) * parent.height : 0 } - visible: cropOverlay.showCropContent - color: "transparent" - border.color: "white" - border.width: 3 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - - // Rotation Handle Line - Rectangle { - id: handleLine - 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" - anchors.top: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - } - - // Rotation Knob - Rectangle { - id: rotateKnob - 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 - color: "white" - border.color: "black" - border.width: 1 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) - anchors.verticalCenter: handleLine.bottom - anchors.horizontalCenter: handleLine.horizontalCenter - } - } - - } - source: loupeView.displayedImageSource function _currentDpr() { @@ -524,7 +463,7 @@ Item { // If we intended to keep high-res (isZoomed is true), preserve capabilities. // If not (isZoomed is false), reset to "fit" state for speed and consistency. if (loupeView.uiStateRef && !loupeView.uiStateRef.isZoomed) { - mainMouseArea.cropRotation = 0 + mainMouseArea.resetCropRotation() mainMouseArea.isRotating = false mainMouseArea.cropDragMode = "none" @@ -620,6 +559,227 @@ Item { + } + + // Crop overlay lives in viewport space so straighten rotates the image + // inside a fixed output window, matching Photoshop's crop behavior. + Item { + id: cropOverlay + anchors.fill: parent + z: 100 + visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping + + property int _cropBoxRev: 0 + property bool isFullImageCrop: { + var b = _liveCropBox() + return b && b.length === 4 && b[0] === 0 && b[1] === 0 && b[2] === 1000 && b[3] === 1000 + } + property bool hasPositiveCrop: { + var b = _liveCropBox() + return b && b.length === 4 && (b[2] - b[0]) > 0 && (b[3] - b[1]) > 0 + } + property bool hasDrawableCrop: hasPositiveCrop && !isFullImageCrop + property bool showCropContent: hasDrawableCrop || mainMouseArea.isRotating + + Connections { + target: loupeView.uiStateRef + function onCurrentCropBoxChanged() { + cropOverlay._cropBoxRev += 1 + } + } + + function _liveCropBox() { + var _ = cropOverlay._cropBoxRev + return loupeView.uiStateRef ? loupeView.uiStateRef.currentCropBox : null + } + + function _dimensionsAreSwapped() { + // Symmetric about zero so +/-45 behave identically; mirrors the + // backend's round(angle / 90) % 2 convention (editor.py). + return Math.round(Math.abs(mainMouseArea.cropRotation) / 90) % 2 === 1 + } + + function cropViewRectForBox(box) { + // Touch every input that affects the mainImage -> cropOverlay mapping + // so this binding re-evaluates when any changes (QML can't track + // dependencies through mapFromItem). The straighten Rotation pivot + // (cropRotationPivotX/Y) is included so a pivot-only change still + // invalidates the crop frame. + var _ = cropOverlay._cropBoxRev + mainMouseArea.cropRotation + mainMouseArea.cropRotationPivotX + mainMouseArea.cropRotationPivotY + imageRotator.zoomScale + panTransform.x + panTransform.y + mainImage.width + mainImage.height + cropOverlay.width + cropOverlay.height + if (!box || box.length !== 4 || !mainImage || mainImage.width <= 0 || mainImage.height <= 0) { + return {x: 0, y: 0, width: 0, height: 0} + } + + var centerX = ((box[0] + box[2]) / 2000) * mainImage.width + var centerY = ((box[1] + box[3]) / 2000) * mainImage.height + var center = cropOverlay.mapFromItem(mainImage, centerX, centerY) + var rectW = ((box[2] - box[0]) / 1000) * mainImage.width * imageRotator.zoomScale + var rectH = ((box[3] - box[1]) / 1000) * mainImage.height * imageRotator.zoomScale + + if (_dimensionsAreSwapped()) { + var tmp = rectW + rectW = rectH + rectH = tmp + } + + return { + x: center.x - rectW / 2, + y: center.y - rectH / 2, + width: rectW, + height: rectH + } + } + + function currentCropViewRect() { + return cropViewRectForBox(_liveCropBox()) + } + + // Convert a viewport-space rect to a normalized 0-1000 crop box. + // + // preserveSize=true keeps the box's size and slides it to stay inside + // the image (used for "move", which should translate without resizing). + // preserveSize=false clips each edge independently at the image boundary + // so the dragged edge stops at the edge while the anchored edge stays put + // (used for resize and new-crop drawing). + function cropBoxFromViewRect(left, top, right, bottom, preserveSize) { + if (!mainImage || mainImage.width <= 0 || mainImage.height <= 0 || imageRotator.zoomScale <= 0) { + return [0, 0, 1000, 1000] + } + + var rectLeft = Math.min(left, right) + var rectRight = Math.max(left, right) + var rectTop = Math.min(top, bottom) + var rectBottom = Math.max(top, bottom) + + if (rectRight - rectLeft < 1) rectRight = rectLeft + 1 + if (rectBottom - rectTop < 1) rectBottom = rectTop + 1 + + var center = mainImage.mapFromItem(cropOverlay, (rectLeft + rectRight) / 2, (rectTop + rectBottom) / 2) + var sourceW = (rectRight - rectLeft) / imageRotator.zoomScale + var sourceH = (rectBottom - rectTop) / imageRotator.zoomScale + + if (_dimensionsAreSwapped()) { + var tmp = sourceW + sourceW = sourceH + sourceH = tmp + } + + var normW = sourceW * 1000 / mainImage.width + var normH = sourceH * 1000 / mainImage.height + var cx = center.x * 1000 / mainImage.width + var cy = center.y * 1000 / mainImage.height + + var outLeft = cx - normW / 2 + var outRight = cx + normW / 2 + var outTop = cy - normH / 2 + var outBottom = cy + normH / 2 + + if (preserveSize) { + // Slide the whole box back inside [0,1000] without resizing it. + if (outRight - outLeft >= 1000) { + outLeft = 0 + outRight = 1000 + } else { + if (outLeft < 0) { + outRight -= outLeft + outLeft = 0 + } + if (outRight > 1000) { + outLeft -= outRight - 1000 + outRight = 1000 + } + } + if (outBottom - outTop >= 1000) { + outTop = 0 + outBottom = 1000 + } else { + if (outTop < 0) { + outBottom -= outTop + outTop = 0 + } + if (outBottom > 1000) { + outTop -= outBottom - 1000 + outBottom = 1000 + } + } + } else { + // Clip each edge at the image boundary so the dragged edge stops + // there while the anchored (opposite) edge stays fixed. + outLeft = Math.max(0, Math.min(1000, outLeft)) + outRight = Math.max(0, Math.min(1000, outRight)) + outTop = Math.max(0, Math.min(1000, outTop)) + outBottom = Math.max(0, Math.min(1000, outBottom)) + } + + // Enforce a minimum crop extent in normalized units (~1% of the + // image), independent of zoom so a high-zoom drag can't create a + // degenerate crop. + var minNorm = 10 + if (outRight - outLeft < minNorm) { + if (outLeft + minNorm <= 1000) outRight = outLeft + minNorm + else outLeft = outRight - minNorm + } + if (outBottom - outTop < minNorm) { + if (outTop + minNorm <= 1000) outBottom = outTop + minNorm + else outTop = outBottom - minNorm + } + + outLeft = Math.max(0, Math.min(1000 - minNorm, outLeft)) + outTop = Math.max(0, Math.min(1000 - minNorm, outTop)) + outRight = Math.max(outLeft + minNorm, Math.min(1000, outRight)) + outBottom = Math.max(outTop + minNorm, Math.min(1000, outBottom)) + + return [Math.round(outLeft), Math.round(outTop), Math.round(outRight), Math.round(outBottom)] + } + + // Dimmer rectangles. The crop frame can extend past the viewport (for + // example while straightened), so clamp every edge to the overlay + // bounds to avoid negative/oversized dim regions. + readonly property real _dimLeft: Math.max(0, Math.min(width, cropRect.x)) + readonly property real _dimRight: Math.max(0, Math.min(width, cropRect.x + cropRect.width)) + readonly property real _dimTop: Math.max(0, Math.min(height, cropRect.y)) + readonly property real _dimBottom: Math.max(0, Math.min(height, cropRect.y + cropRect.height)) + + Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: 0; width: parent.width; height: cropOverlay._dimTop; color: "black"; opacity: 0.3 } + Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropOverlay._dimBottom; width: parent.width; height: parent.height - cropOverlay._dimBottom; color: "black"; opacity: 0.3 } + Rectangle { visible: cropOverlay.hasDrawableCrop; x: 0; y: cropOverlay._dimTop; width: cropOverlay._dimLeft; height: cropOverlay._dimBottom - cropOverlay._dimTop; color: "black"; opacity: 0.3 } + Rectangle { visible: cropOverlay.hasDrawableCrop; x: cropOverlay._dimRight; y: cropOverlay._dimTop; width: parent.width - cropOverlay._dimRight; height: cropOverlay._dimBottom - cropOverlay._dimTop; color: "black"; opacity: 0.3 } + + Rectangle { + id: cropRect + property var viewRect: cropOverlay.currentCropViewRect() + x: viewRect.x + y: viewRect.y + width: viewRect.width + height: viewRect.height + visible: cropOverlay.showCropContent + color: "transparent" + border.color: "white" + border.width: 3 + + Rectangle { + id: handleLine + visible: cropOverlay.hasDrawableCrop && mainMouseArea.isRotating + width: 2 + height: 25 + color: "white" + anchors.top: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + } + + Rectangle { + id: rotateKnob + visible: cropOverlay.hasDrawableCrop && mainMouseArea.isRotating + width: 12 + height: 12 + radius: width / 2 + color: "white" + border.color: "black" + border.width: 1 + anchors.verticalCenter: handleLine.bottom + anchors.horizontalCenter: handleLine.horizontalCenter + } + } } // Alignment grid for rotate mode. Lives in the viewport (NOT inside @@ -706,7 +866,13 @@ Item { property real cropBoxStartTop: 0 property real cropBoxStartRight: 0 property real cropBoxStartBottom: 0 + property real cropViewStartLeft: 0 + property real cropViewStartTop: 0 + property real cropViewStartRight: 0 + property real cropViewStartBottom: 0 property real cropRotation: 0 + property real cropRotationPivotX: 0.5 + property real cropRotationPivotY: 0.5 property bool isRotating: false property real cropStartAngle: 0 property real cropStartRotation: 0 @@ -716,7 +882,7 @@ Item { Connections { target: loupeView.uiStateRef function onCurrentIndexChanged() { - mainMouseArea.cropRotation = 0 + mainMouseArea.resetCropRotation() } } @@ -754,6 +920,19 @@ Item { pendingAspect = -1 } + // Reset the straighten angle and its rotation pivot to the centered + // defaults. Centralized so every crop-exit / image-change path stays in + // sync (missing a pivot reset leaks a stale off-center rotation origin + // into the next crop session). + function resetCropRotation() { + // Stop any in-flight throttle so a queued set_straighten_angle() + // can't apply a stale angle to the next crop / image after reset. + rotationThrottleTimer.stop() + cropRotation = 0 + cropRotationPivotX = 0.5 + cropRotationPivotY = 0.5 + } + function setCropBoxStart(left, top, right, bottom) { cropBoxStartLeft = left cropBoxStartTop = top @@ -761,9 +940,18 @@ Item { cropBoxStartBottom = bottom } + function setCropViewStart(left, top, right, bottom) { + cropViewStartLeft = left + cropViewStartTop = top + cropViewStartRight = right + cropViewStartBottom = bottom + } + function setCropBoxStartFromBox(box) { if (!box || box.length !== 4) return setCropBoxStart(box[0], box[1], box[2], box[3]) + var r = cropOverlay.cropViewRectForBox(box) + setCropViewStart(r.x, r.y, r.x + r.width, r.y + r.height) } function hasRightButton(mouse) { @@ -787,10 +975,12 @@ Item { function beginNewCrop(mouseX, mouseY, mx, my) { var clampedMx = Math.max(0, Math.min(1000, mx)) var clampedMy = Math.max(0, Math.min(1000, my)) + var p = cropOverlay.mapFromItem(mainMouseArea, mouseX, mouseY) cropDragMode = "new" cropStartX = mouseX cropStartY = mouseY setCropBoxStart(clampedMx, clampedMy, clampedMx, clampedMy) + setCropViewStart(p.x, p.y, p.x, p.y) } function beginCropInteraction() { @@ -896,7 +1086,7 @@ Item { } if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping) { - // Check if clicking on existing crop box - Using Image Space Hit Testing + // Check if clicking on existing crop box in viewport space. var box = loupeView.uiStateRef.currentCropBox if (box && box.length === 4) box = box.slice(0) @@ -905,28 +1095,21 @@ Item { var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) var mx = coords.x * 1000 var my = coords.y * 1000 - - // Calculate threshold in normalized units (approx 10 screen pixels) - var threshX = (10 / (scaleTransform.xScale * mainImage.width)) * 1000 - var threshY = (10 / (scaleTransform.yScale * mainImage.height)) * 1000 - - // Clamp threshold: min 5 normalized units (prevent too small), max 40 (prevent too large) - // This ensures handles remain usable at all zoom levels - var edgeThreshold = Math.max(5, Math.min(40, Math.max(threshX, threshY))) - - // 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) + var viewPoint = cropOverlay.mapFromItem(mainMouseArea, mouse.x, mouse.y) + var viewRect = cropOverlay.cropViewRectForBox(box) + var viewLeft = viewRect.x + var viewTop = viewRect.y + var viewRight = viewRect.x + viewRect.width + var viewBottom = viewRect.y + viewRect.height + // Scale grab tolerance with DPI to match the rotate-knob hit test + // (which uses Screen.devicePixelRatio) so edges and the knob feel + // equally forgiving on HiDPI displays. + var edgeThreshold = 10 * Screen.devicePixelRatio + + // Make it so the user doesn't have to click exactly on the crop box to modify it. + var inside = viewPoint.x >= (viewLeft - edgeThreshold) && viewPoint.x <= (viewRight + edgeThreshold) && viewPoint.y >= (viewTop - edgeThreshold) && viewPoint.y <= (viewBottom + edgeThreshold) if (mainMouseArea.isRotating && cropOverlay.visible && rotateKnob.visible) { - // knob center in mainMouseArea coords (includes cropRect rotation) - // Note: rotateKnob is now inside mainImage -> cropOverlay -> cropRect - // But mapFromItem should still work if we target the object properly. - // We need to resolve `rotateKnob` which is inside cropOverlay. - // If cropOverlay moves, we need to ensure this binding works. - // IMPORTANT: cropOverlay is not moved yet in this call. - // Current logic relies on existing structure. I will defer logic update if structure changes. - // But hit testing via mapFromItem(rotateKnob) is robust to hierarchy changes as long as rotateKnob exists. - var k = mainMouseArea.mapFromItem(rotateKnob, rotateKnob.width/2, rotateKnob.height/2) var dxk = mouse.x - k.x var dyk = mouse.y - k.y @@ -935,8 +1118,14 @@ Item { if (distk < 22 * Screen.devicePixelRatio) { // a little forgiving cropDragMode = "rotate" - // crop center in mainMouseArea coords -> Changed to IMAGE center to avoid feedback loop - var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) + // Seed cropBoxStart variables before deriving the fixed crop pivot. + if (box && box.length === 4) { + setCropBoxStartFromBox(box) + cropRotationPivotX = ((box[0] + box[2]) / 2000) + cropRotationPivotY = ((box[1] + box[3]) / 2000) + } + + var c = mainMouseArea.mapFromItem(cropOverlay, (cropViewStartLeft + cropViewStartRight) / 2, (cropViewStartTop + cropViewStartBottom) / 2) cropStartAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI cropStartRotation = cropRotation @@ -949,27 +1138,23 @@ Item { } } - - // Seed cropBoxStart variables - if (box && box.length === 4) { - setCropBoxStartFromBox(box) - } - beginCropInteraction() return } } - // If crop box is full image, always start a new crop - else if (isFullImage) { + // If crop box is full image, always start a new crop. + // Independent of the rotate-knob check above so crop hit-testing + // still runs when a knob click is missed in rotate mode. + if (isFullImage) { // Start a new crop rectangle from the clicked point 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 - var nearRight = Math.abs(mx - box[2]) < edgeThreshold - var nearTop = Math.abs(my - box[1]) < edgeThreshold - var nearBottom = Math.abs(my - box[3]) < edgeThreshold + // Determine which edge/corner is being dragged in viewport space. + var nearLeft = Math.abs(viewPoint.x - viewLeft) < edgeThreshold + var nearRight = Math.abs(viewPoint.x - viewRight) < edgeThreshold + var nearTop = Math.abs(viewPoint.y - viewTop) < edgeThreshold + var nearBottom = Math.abs(viewPoint.y - viewBottom) < edgeThreshold if (nearLeft && nearTop) cropDragMode = "topleft" else if (nearRight && nearTop) cropDragMode = "topright" @@ -981,7 +1166,12 @@ Item { else if (nearBottom) cropDragMode = "bottom" else cropDragMode = "move" - setCropBoxStartFromBox(box) + cropStartX = mouse.x + cropStartY = mouse.y + // Reuse the viewRect already computed above instead of + // recomputing cropViewRectForBox inside setCropBoxStartFromBox. + setCropBoxStart(box[0], box[1], box[2], box[3]) + setCropViewStart(viewLeft, viewTop, viewRight, viewBottom) } else { // Start new crop rectangle beginNewCrop(mouse.x, mouse.y, mx, my) @@ -989,8 +1179,7 @@ Item { beginCropInteraction() } } - // Legacy getCropRect removed - using Image Space hit testing instead. - // mapToImageCoordinates maps directly to mainImage + // mapToImageCoordinates maps directly to mainImage for image-local tools. function mapToImageCoordinates(screenPoint) { if (!mainImage) return {x:0, y:0} var w = mainImage.width > 0 ? mainImage.width : mainImage.sourceSize.width @@ -1014,7 +1203,7 @@ Item { // Update crop rectangle while dragging updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y, true) } else if (cropDragMode === "rotate") { - var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) + var c = mainMouseArea.mapFromItem(cropOverlay, (cropViewStartLeft + cropViewStartRight) / 2, (cropViewStartTop + cropViewStartBottom) / 2) var currentAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI var delta = currentAngle - cropStartAngle // Handle wrap-around @@ -1025,7 +1214,13 @@ Item { // Update rotation state cropRotation = newRotation - + + // The crop box is intentionally left unchanged while straightening: + // the image rotates under a fixed crop frame. This matches the + // backend (_crop_box_canvas_rect), which preserves the box and lets + // rotation introduce black fill at the corners instead of silently + // shrinking the user's crop. + // Update rotation in backend live (throttled) if (loupeView.controllerRef) { pendingRotation = cropRotation @@ -1038,47 +1233,40 @@ Item { // Return early to prevent overwriting crop box during rotation return } else { - // Handle move/resize (edge dragging) - var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) - - // Clamp to image bounds and convert to 0-1000 range - var mouseX = Math.max(0, Math.min(1, coords.x)) * 1000 - var mouseY = Math.max(0, Math.min(1, coords.y)) * 1000 - - var left = cropBoxStartLeft - var top = cropBoxStartTop - var right = cropBoxStartRight - var bottom = cropBoxStartBottom + // Handle move/resize against the fixed viewport crop frame. + var startPoint = cropOverlay.mapFromItem(mainMouseArea, cropStartX, cropStartY) + var currentPoint = cropOverlay.mapFromItem(mainMouseArea, mouse.x, mouse.y) + var left = cropViewStartLeft + var top = cropViewStartTop + var right = cropViewStartRight + var bottom = cropViewStartBottom // Adjust based on drag mode if (cropDragMode === "move") { - var startCenterX = (cropBoxStartLeft + cropBoxStartRight) / 2 - var startCenterY = (cropBoxStartTop + cropBoxStartBottom) / 2 - - var dx = mouseX - startCenterX - var dy = mouseY - startCenterY + var dx = currentPoint.x - startPoint.x + var dy = currentPoint.y - startPoint.y - var width = cropBoxStartRight - cropBoxStartLeft - var height = cropBoxStartBottom - cropBoxStartTop + var width = cropViewStartRight - cropViewStartLeft + var height = cropViewStartBottom - cropViewStartTop - left = Math.max(0, Math.min(1000 - width, cropBoxStartLeft + dx)) - top = Math.max(0, Math.min(1000 - height, cropBoxStartTop + dy)) + left = cropViewStartLeft + dx + top = cropViewStartTop + dy right = left + width bottom = top + height } else { - if (cropDragMode.includes("left")) left = mouseX; - if (cropDragMode.includes("right")) right = mouseX; - if (cropDragMode.includes("top")) top = mouseY; - if (cropDragMode.includes("bottom")) bottom = mouseY; - - var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, cropDragMode) - left = constrainedBox[0] - top = constrainedBox[1] - right = constrainedBox[2] - bottom = constrainedBox[3] + if (cropDragMode.includes("left")) left = currentPoint.x; + if (cropDragMode.includes("right")) right = currentPoint.x; + if (cropDragMode.includes("top")) top = currentPoint.y; + if (cropDragMode.includes("bottom")) bottom = currentPoint.y; } - - loupeView.uiStateRef.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + + var nextBox = cropOverlay.cropBoxFromViewRect(left, top, right, bottom, cropDragMode === "move") + if (cropDragMode !== "move") { + var constrainedBox = applyAspectRatioConstraint(nextBox[0], nextBox[1], nextBox[2], nextBox[3], cropDragMode) + nextBox = [constrainedBox[0], constrainedBox[1], constrainedBox[2], constrainedBox[3]] + } + + loupeView.uiStateRef.currentCropBox = nextBox } return } @@ -1197,20 +1385,12 @@ Item { function updateCropBox(x1, y1, x2, y2, applyAspectRatio = false) { if (!loupeView.uiStateRef || !mainImage.source) return - var imgCoord1 = mapToImageCoordinates(Qt.point(x1, y1)) - var imgCoord2 = mapToImageCoordinates(Qt.point(x2, y2)) - - // Clamp to image bounds (normalized 0-1) - var imgCoordX1 = Math.max(0, Math.min(1, imgCoord1.x)) - var imgCoordY1 = Math.max(0, Math.min(1, imgCoord1.y)) - var imgCoordX2 = Math.max(0, Math.min(1, imgCoord2.x)) - var imgCoordY2 = Math.max(0, Math.min(1, imgCoord2.y)) - - // Calculate raw box in 0-1000 space - var left = Math.min(imgCoordX1, imgCoordX2) * 1000 - var right = Math.max(imgCoordX1, imgCoordX2) * 1000 - var top = Math.min(imgCoordY1, imgCoordY2) * 1000 - var bottom = Math.max(imgCoordY1, imgCoordY2) * 1000 + var p1 = cropOverlay.mapFromItem(mainMouseArea, x1, y1) + var p2 = cropOverlay.mapFromItem(mainMouseArea, x2, y2) + var left = Math.min(p1.x, p2.x) + var right = Math.max(p1.x, p2.x) + var top = Math.min(p1.y, p2.y) + var bottom = Math.max(p1.y, p2.y) // Determine primary drag direction for "new" mode (from anchor x1,y1 to mouse x2,y2) // We need to know which corner is the anchor to apply aspect ratio correctly @@ -1225,29 +1405,23 @@ Item { else if (x2 >= x1 && y2 < y1) mode = "topright" else if (x2 < x1 && y2 < y1) mode = "topleft" - // Pass the raw coordinates of the "mouse" corner (x2, y2) and the "anchor" corner (x1, y1) - // But applyAspectRatioConstraint expects left, top, right, bottom. - // It assumes one corner is fixed based on mode. - // So we pass the current box, and it will adjust the moving corner. - - var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, mode) - left = constrainedBox[0] - top = constrainedBox[1] - right = constrainedBox[2] - bottom = constrainedBox[3] + var nextBox = cropOverlay.cropBoxFromViewRect(left, top, right, bottom) + var constrainedBox = applyAspectRatioConstraint(nextBox[0], nextBox[1], nextBox[2], nextBox[3], mode) + loupeView.uiStateRef.currentCropBox = [constrainedBox[0], constrainedBox[1], constrainedBox[2], constrainedBox[3]] + return } else { // Just ensure minimum size if (right - left < 10) { - if (right < 1000) right = Math.min(1000, left + 10) - else left = Math.max(0, right - 10) + if (p2.x >= p1.x) right = left + 10 + else left = right - 10 } if (bottom - top < 10) { - if (bottom < 1000) bottom = Math.min(1000, top + 10) - else top = Math.max(0, bottom - 10) + if (p2.y >= p1.y) bottom = top + 10 + else top = bottom - 10 } } - loupeView.uiStateRef.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + loupeView.uiStateRef.currentCropBox = cropOverlay.cropBoxFromViewRect(left, top, right, bottom) } function getAspectRatio(name) { @@ -1285,6 +1459,11 @@ Item { // width_norm / height_norm = targetAspect * (imgH / imgW) var pixelAspect = ratioPair[0] / ratioPair[1]; + // At an odd 90-degree straighten the committed output swaps width + // and height (editor.py _crop_box_canvas_rect), so a 16:9 lock must + // constrain the source box to 9:16 to land 16:9 after the swap. + // Mirror the swap cropViewRectForBox/cropBoxFromViewRect already do. + if (cropOverlay._dimensionsAreSwapped()) pixelAspect = 1.0 / pixelAspect; // Use mainImage (fixed canvas) for aspect ratio calculation var imageAspect = mainImage.width / mainImage.height; var targetAspect = pixelAspect * (1.0 / imageAspect); // Normalized aspect ratio diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 975daa2..93b19fb 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1068,8 +1068,11 @@ ApplicationWindow { hoverFillColor: root.menuHoverColor defaultTextColor: root.currentTextColor onClicked: { - if (root.uiStateRef) root.uiStateRef.jumpToLastUploaded() + sortSubMenu.close() actionsMenu.close() + Qt.callLater(function() { + if (root.uiStateRef) root.uiStateRef.jumpToLastUploaded() + }) } } MenuActionItem { @@ -1880,8 +1883,8 @@ ApplicationWindow { "  Ctrl+0: Reset zoom and pan to fit window
" + "  Ctrl+1/2/3/4: Zoom to 100%/200%/300%/400%

" + "Stacking:
" + - "  [: Begin new stack
" + - "  ]: End current stack
" + + "  [: Mark stack start
" + + "  ]: Mark stack end
" + "  C: Clear all stacks
" + "  S: Toggle current image in/out of stack
" + "  X: Remove current image from batch/stack" diff --git a/faststack/updater.py b/faststack/updater.py index bb010b6..5aff54a 100644 --- a/faststack/updater.py +++ b/faststack/updater.py @@ -25,6 +25,10 @@ LATEST_RELEASE_URL = f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest" USER_AGENT = "FastStack Update Checker" FALLBACK_VERSION = "1.6.4" +BUILD_SUFFIX_RE = re.compile( + r"[-_.+]?build[-_.]?\d+(?:[-_.].*)?$", + re.IGNORECASE, +) class UpdateCheckError(RuntimeError): @@ -89,12 +93,13 @@ def normalize_version(version: str) -> str: value = version.strip() if value.startswith(("v", "V")): value = value[1:] - value = re.sub(r"[-_.]?build\d+$", "", value, flags=re.IGNORECASE) + value = value.split("+", 1)[0] + value = BUILD_SUFFIX_RE.sub("", value) return value def is_newer_version(latest: str, current: str) -> bool: - """Return True when latest is newer than current.""" + """Return True when latest has a newer public version than current.""" if Version is None: return _fallback_version_key(latest) > _fallback_version_key(current)