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)