diff --git a/faststack/qml/BatchProgressDialog.qml b/faststack/qml/BatchProgressDialog.qml index a13bc37..9b4f2dd 100644 --- a/faststack/qml/BatchProgressDialog.qml +++ b/faststack/qml/BatchProgressDialog.qml @@ -13,6 +13,7 @@ Dialog { property color backgroundColor: "#1e1e1e" property color textColor: "white" + property var uiStateRef: typeof uiState !== "undefined" ? uiState : null background: Rectangle { color: batchProgressDialog.backgroundColor @@ -28,9 +29,9 @@ Dialog { Label { id: statusLabel text: { - if (!uiState) return "" - var current = uiState.batchAutoLevelsCurrent - var total = uiState.batchAutoLevelsTotal + if (!batchProgressDialog.uiStateRef) return "" + var current = batchProgressDialog.uiStateRef.batchAutoLevelsCurrent + var total = batchProgressDialog.uiStateRef.batchAutoLevelsTotal return `Processing image ${current} of ${total}...` } color: batchProgressDialog.textColor @@ -42,8 +43,8 @@ Dialog { id: progressBar width: parent.width - parent.padding * 2 from: 0 - to: uiState ? uiState.batchAutoLevelsTotal : 1 - value: uiState ? uiState.batchAutoLevelsCurrent : 0 + to: batchProgressDialog.uiStateRef ? batchProgressDialog.uiStateRef.batchAutoLevelsTotal : 1 + value: batchProgressDialog.uiStateRef ? batchProgressDialog.uiStateRef.batchAutoLevelsCurrent : 0 background: Rectangle { implicitHeight: 12 @@ -62,17 +63,18 @@ Dialog { } Button { + id: cancelButton text: "Cancel" anchors.horizontalCenter: parent.horizontalCenter onClicked: { - if (uiState) uiState.cancelBatchAutoLevels() + if (batchProgressDialog.uiStateRef) batchProgressDialog.uiStateRef.cancelBatchAutoLevels() } background: Rectangle { - color: parent.pressed ? "#555555" : (parent.hovered ? "#666666" : "#444444") + color: cancelButton.down ? "#555555" : (cancelButton.hovered ? "#666666" : "#444444") radius: 4 } contentItem: Text { - text: parent.text + text: cancelButton.text color: batchProgressDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -81,9 +83,9 @@ Dialog { } Connections { - target: uiState + target: batchProgressDialog.uiStateRef function onBatchAutoLevelsActiveChanged() { - if (uiState && uiState.batchAutoLevelsActive) { + if (batchProgressDialog.uiStateRef && batchProgressDialog.uiStateRef.batchAutoLevelsActive) { batchProgressDialog.open() } else { batchProgressDialog.close() diff --git a/faststack/qml/Components.qml b/faststack/qml/Components.qml index 2e353ff..1416b12 100644 --- a/faststack/qml/Components.qml +++ b/faststack/qml/Components.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Window @@ -10,18 +12,33 @@ Item { focus: true // Height of the status bar footer in Main.qml + property var uiStateRef: null + property var controllerRef: null + property bool isDarkTheme: true property int footerHeight: 60 // 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 : "" + // Freeze the displayed source for the full crop session once crop mode + // starts. Zoom-triggered high-res swaps stay blocked until crop mode exits, + // because any async source swap during cropping can rescale the image and + // invalidate the crop box's visual alignment. + readonly property string requestedImageSource: loupeView.uiStateRef && loupeView.uiStateRef.imageCount > 0 ? loupeView.uiStateRef.currentImageSource : "" property string cropDragImageSource: "" - readonly property bool isCropSourceFrozen: cropDragImageSource !== "" && ((mainMouseArea && mainMouseArea.isCropDragging) || (uiState && uiState.isCropping)) + readonly property bool isCropSourceFrozen: cropDragImageSource !== "" && ((mainMouseArea && mainMouseArea.isCropDragging) || (loupeView.uiStateRef && loupeView.uiStateRef.isCropping)) readonly property string displayedImageSource: isCropSourceFrozen ? cropDragImageSource : requestedImageSource + Component.onCompleted: { + loupeView.uiStateRef = uiState + loupeView.controllerRef = controller + // mainImage may complete before uiStateRef is wired, so retry the + // initial size report once from the parent if the child call no-op'd. + if (mainImage && !mainImage.initialDisplaySizeReported) { + mainImage.reportDisplaySize() + } + } + function freezeCropImageSource() { if (cropDragImageSource === "") { cropDragImageSource = mainImage && mainImage.source ? mainImage.source : requestedImageSource @@ -33,7 +50,7 @@ Item { } Connections { - target: uiState + target: loupeView.uiStateRef function onCurrentIndexChanged() { // Smart High-Res Logic: // Before the new image loads, decide if we should keep high-res mode. @@ -42,14 +59,16 @@ Item { if (imageRotator.zoomScale > imageRotator.fitScale * 1.1) { // Keep high-res (setZoomed true if not already) - if (!uiState.isZoomed) uiState.setZoomed(true) + if (!loupeView.uiStateRef.isZoomed) loupeView.uiStateRef.setZoomed(true) } else { // Drop to low-res for the next image - if (uiState.isZoomed) uiState.setZoomed(false) + if (loupeView.uiStateRef.isZoomed) loupeView.uiStateRef.setZoomed(false) } } function onIsCroppingChanged() { - if (uiState && uiState.isCropping) { + if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping) { + // Capture the session's visual source when crop mode turns on, + // then keep it stable until crop mode exits. loupeView.freezeCropImageSource() } else { if (mainMouseArea) { @@ -64,20 +83,20 @@ Item { } Keys.onEscapePressed: (event) => { - if (uiState && uiState.isCropping) { + if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping) { if (mainMouseArea.isRotating) { // Revert rotation mainMouseArea.cropRotation = mainMouseArea.cropStartRotation mainMouseArea.clearPendingRotation(mainMouseArea.cropRotation) - if (controller) controller.set_straighten_angle(mainMouseArea.cropRotation, -1) + if (loupeView.controllerRef) loupeView.controllerRef.set_straighten_angle(mainMouseArea.cropRotation, -1) mainMouseArea.endCropInteraction() mainMouseArea.isRotating = false event.accepted = true - } else if (controller) { + } else if (loupeView.controllerRef) { mainMouseArea.clearPendingRotation(0) mainMouseArea.endCropInteraction() - controller.cancel_crop_mode() + loupeView.controllerRef.cancel_crop_mode() mainMouseArea.cropRotation = 0 // Reset local rotation mainMouseArea.isRotating = false event.accepted = true @@ -89,20 +108,20 @@ Item { // Zoom Shortcuts (Ctrl+1..4) if (event.modifiers & Qt.ControlModifier) { if (event.key === Qt.Key_1) { - uiState.request_absolute_zoom(1.0) + loupeView.uiStateRef.request_absolute_zoom(1.0) event.accepted = true return } else if (event.key === Qt.Key_2) { - uiState.request_absolute_zoom(2.0) + loupeView.uiStateRef.request_absolute_zoom(2.0) event.accepted = true return } else if (event.key === Qt.Key_3) { - uiState.request_absolute_zoom(3.0) + loupeView.uiStateRef.request_absolute_zoom(3.0) event.accepted = true return } else if (event.key === Qt.Key_4) { // 400% zoom - uiState.request_absolute_zoom(4.0) + loupeView.uiStateRef.request_absolute_zoom(4.0) event.accepted = true return } @@ -110,14 +129,14 @@ Item { // Handle Enter for Crop Execution (formerly Keys.onEnterPressed) // We only accept the event if we actually act on it. - if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && uiState && uiState.isCropping && controller) { + if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && loupeView.uiStateRef && loupeView.uiStateRef.isCropping && loupeView.controllerRef) { // Force immediate rotation update before executing crop if (mainMouseArea.cropRotation !== 0) { - controller.set_straighten_angle(mainMouseArea.cropRotation, -1) + loupeView.controllerRef.set_straighten_angle(mainMouseArea.cropRotation, -1) } - uiState.setZoomed(false) // Force unzoom - controller.execute_crop() + loupeView.uiStateRef.setZoomed(false) // Force unzoom + loupeView.controllerRef.execute_crop() event.accepted = true return } @@ -130,7 +149,7 @@ Item { // Connection to handle zoom/pan reset signal from Python Connections { - target: uiState + target: loupeView.uiStateRef function onResetZoomPanRequested() { imageRotator.zoomScale = imageRotator.fitScale panTransform.x = 0 @@ -141,9 +160,9 @@ Item { // If we need to switch to high-res, flag this scale as the target // for the incoming source change so recomputeFitScale doesn't clobber it. - if (uiState && !uiState.isZoomed) { + if (loupeView.uiStateRef && !loupeView.uiStateRef.isZoomed) { imageRotator.targetAbsoluteZoom = scale - uiState.setZoomed(true) + loupeView.uiStateRef.setZoomed(true) } } } @@ -155,7 +174,7 @@ Item { anchors.right: parent.right anchors.top: parent.top anchors.bottom: parent.bottom - anchors.bottomMargin: footerHeight + anchors.bottomMargin: loupeView.footerHeight clip: true // Container that handles Rotation (Straightening) @@ -317,7 +336,7 @@ Item { Image { id: mainImage anchors.centerIn: parent - visible: uiState && !uiState.isGridViewActive + visible: loupeView.uiStateRef && !loupeView.uiStateRef.isGridViewActive // Image size is now updated atomically in updateRotatorGeometry to prevent distortion // width: sourceSize.width @@ -330,9 +349,9 @@ Item { id: darkenOverlay anchors.fill: parent z: 90 - visible: uiState && uiState.isDarkening && uiState.darkenOverlayVisible - source: (uiState && uiState.isDarkening && uiState.darkenOverlayVisible) - ? "image://provider/mask_overlay/" + uiState.darkenOverlayGeneration + visible: loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && loupeView.uiStateRef.darkenOverlayVisible + source: (loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && loupeView.uiStateRef.darkenOverlayVisible) + ? "image://provider/mask_overlay/" + loupeView.uiStateRef.darkenOverlayGeneration : "" fillMode: Image.Stretch cache: false @@ -342,13 +361,13 @@ Item { // Crop overlay - anchored to mainImage to rotate with it Item { id: cropOverlay - property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] + property var cropBox: loupeView.uiStateRef ? loupeView.uiStateRef.currentCropBox : [0, 0, 1000, 1000] property bool hasActiveCrop: cropBox && cropBox.length === 4 && !(cropBox[0]===0 && cropBox[1]===0 && cropBox[2]===1000 && cropBox[3]===1000) // Show visual content only when there is an actual user-drawn crop or rotate mode. // The overlay Item itself stays alive (visible: isCropping) so updateCropRect() always fires. property bool showCropContent: hasActiveCrop || mainMouseArea.isRotating - visible: uiState && uiState.isCropping + visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping anchors.fill: parent // Fills mainImage z: 100 @@ -356,7 +375,7 @@ Item { Component.onCompleted: { if (parent.source) updateCropRect() } Connections { - target: uiState + target: loupeView.uiStateRef function onCurrentCropBoxChanged() { if (mainImage.source) cropOverlay.updateCropRect() } } @@ -367,8 +386,8 @@ Item { } function updateCropRect() { - if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return - var box = uiState.currentCropBox + if (!loupeView.uiStateRef || !loupeView.uiStateRef.currentCropBox || loupeView.uiStateRef.currentCropBox.length !== 4) return + var box = loupeView.uiStateRef.currentCropBox // Local coords in mainImage (Source Space) var localLeft = (box[0] / 1000) * parent.width @@ -425,9 +444,8 @@ Item { source: loupeView.displayedImageSource function _currentDpr() { - // Per-window DPR is the safest (multi-monitor setups) - if (mainImage.window && mainImage.window.devicePixelRatio) - return mainImage.window.devicePixelRatio + // Fall back to the current screen DPR; qmllint does not + // recognize a stable per-window DPR property here. return Screen.devicePixelRatio } @@ -479,7 +497,7 @@ Item { // Smart Zoom Reset: // 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 (uiState && !uiState.isZoomed) { + if (loupeView.uiStateRef && !loupeView.uiStateRef.isZoomed) { mainMouseArea.cropRotation = 0 mainMouseArea.isRotating = false mainMouseArea.cropDragMode = "none" @@ -496,15 +514,17 @@ Item { property bool _sourceSizeStale: false property bool isZooming: false + property bool initialDisplaySizeReported: false // IMPORTANT: tell Python the *viewport* size, not the sourceSize size function reportDisplaySize() { - if (imageViewport.width > 0 && imageViewport.height > 0) { + if (loupeView.uiStateRef && imageViewport.width > 0 && imageViewport.height > 0) { var dpr = Screen.devicePixelRatio - uiState.onDisplaySizeChanged( + loupeView.uiStateRef.onDisplaySizeChanged( Math.round(imageViewport.width * dpr), Math.round(imageViewport.height * dpr) ) + initialDisplaySizeReported = true } } @@ -523,14 +543,14 @@ Item { interval: 200 // 200ms debounce to prevent thrashing repeat: false onTriggered: { - if (uiState && uiState.isZoomed) { - uiState.setZoomed(false) + if (loupeView.uiStateRef && loupeView.uiStateRef.isZoomed) { + loupeView.uiStateRef.setZoomed(false) } } } function updateZoomState() { - if (!uiState) return; + if (!loupeView.uiStateRef) return; // Thresholds for hysteresis var highResThreshold = imageRotator.fitScale * 1.1 @@ -539,14 +559,14 @@ Item { // Enable High-Res if zoomed in significantly if (imageRotator.zoomScale > highResThreshold) { lowResDebounceTimer.stop() - if (!uiState.isZoomed) { - uiState.setZoomed(true); + if (!loupeView.uiStateRef.isZoomed) { + loupeView.uiStateRef.setZoomed(true); } } // Disable High-Res (return to low-res) if zoomed out to near-fit // formatting note: added hysteresis check AND debounce else if (imageRotator.zoomScale <= lowResThreshold) { - if (uiState.isZoomed) { + if (loupeView.uiStateRef.isZoomed) { // Only drop to low-res after delay to handle wheel overshoot/jitter if (!lowResDebounceTimer.running) lowResDebounceTimer.start() } @@ -559,12 +579,12 @@ Item { } function updateHistogramWithZoom() { - if (uiState && uiState.isHistogramVisible && controller) { + if (loupeView.uiStateRef && loupeView.uiStateRef.isHistogramVisible && loupeView.controllerRef) { var zoom = imageRotator.zoomScale var panX = panTransform.x var panY = panTransform.y var imageScale = imageRotator.zoomScale - controller.update_histogram(zoom, panX, panY, imageScale) + loupeView.controllerRef.update_histogram(zoom, panX, panY, imageScale) } } @@ -587,8 +607,8 @@ Item { acceptedButtons: Qt.LeftButton | Qt.RightButton hoverEnabled: true cursorShape: { - if (uiState && uiState.isDarkening) return Qt.CrossCursor - if (!uiState || !uiState.isCropping) return Qt.ArrowCursor + if (loupeView.uiStateRef && loupeView.uiStateRef.isDarkening) return Qt.CrossCursor + if (!loupeView.uiStateRef || !loupeView.uiStateRef.isCropping) return Qt.ArrowCursor return Qt.CrossCursor } @@ -619,7 +639,7 @@ Item { // Reset rotation when image changes or updates (e.g. after crop save) to avoid persistence Connections { - target: uiState + target: loupeView.uiStateRef function onCurrentIndexChanged() { mainMouseArea.cropRotation = 0 } @@ -627,12 +647,12 @@ Item { onIsRotatingChanged: { - if (uiState) { + if (loupeView.uiStateRef) { if (isRotating) { - uiState.statusMessage = "Press ESC to exit rotate mode" + loupeView.uiStateRef.statusMessage = "Press ESC to exit rotate mode" } else { - if (uiState.statusMessage === "Press ESC to exit rotate mode") { - uiState.statusMessage = "" + if (loupeView.uiStateRef.statusMessage === "Press ESC to exit rotate mode") { + loupeView.uiStateRef.statusMessage = "" } } } @@ -646,8 +666,8 @@ Item { interval: 32 // ~30 fps repeat: false onTriggered: { - if (controller && uiState && uiState.isCropping) { - controller.set_straighten_angle(mainMouseArea.pendingRotation, mainMouseArea.pendingAspect) + if (loupeView.controllerRef && loupeView.uiStateRef && loupeView.uiStateRef.isCropping) { + loupeView.controllerRef.set_straighten_angle(mainMouseArea.pendingRotation, mainMouseArea.pendingAspect) } } } @@ -677,7 +697,7 @@ Item { cropStartX = mouseX cropStartY = mouseY setCropBoxStart(clampedMx, clampedMy, clampedMx, clampedMy) - uiState.currentCropBox = [Math.round(clampedMx), Math.round(clampedMy), Math.round(clampedMx), Math.round(clampedMy)] + loupeView.uiStateRef.currentCropBox = [Math.round(clampedMx), Math.round(clampedMy), Math.round(clampedMx), Math.round(clampedMy)] } function beginCropInteraction() { @@ -713,7 +733,7 @@ Item { isDraggingOutside = false // Darken painting mode - if (uiState && uiState.isDarkening && !uiState.isCropping && controller) { + if (loupeView.uiStateRef && loupeView.uiStateRef.isDarkening && !loupeView.uiStateRef.isCropping && loupeView.controllerRef) { var imgCoords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) var sx = Math.max(0, Math.min(1, imgCoords.x)) var sy = Math.max(0, Math.min(1, imgCoords.y)) @@ -721,7 +741,7 @@ Item { return // click outside image bounds } var strokeType = (mouse.button === Qt.RightButton) ? "protect" : "add" - controller.start_darken_stroke(sx, sy, strokeType) + loupeView.controllerRef.start_darken_stroke(sx, sy, strokeType) isDarkenPainting = true return } @@ -731,11 +751,11 @@ Item { // source/geometry changes it triggers are properly deferred. beginCropInteraction() - if (!uiState.isCropping && controller) { - controller.toggle_crop_mode() // Ensure mode is ON + if (!loupeView.uiStateRef.isCropping && loupeView.controllerRef) { + loupeView.controllerRef.toggle_crop_mode() // Ensure mode is ON } - if (!uiState || !uiState.isCropping) { + if (!loupeView.uiStateRef || !loupeView.uiStateRef.isCropping) { endCropInteraction() return } @@ -752,9 +772,9 @@ Item { return } - if (uiState && uiState.isCropping) { + if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping) { // Check if clicking on existing crop box - Using Image Space Hit Testing - var box = uiState.currentCropBox + var box = loupeView.uiStateRef.currentCropBox if (box && box.length === 4) box = box.slice(0) var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 @@ -858,15 +878,15 @@ Item { } onPositionChanged: function(mouse) { // Darken painting drag — clamp to image bounds - if (isDarkenPainting && controller) { + if (isDarkenPainting && loupeView.controllerRef) { var imgCoords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) var cx = Math.max(0, Math.min(1, imgCoords.x)) var cy = Math.max(0, Math.min(1, imgCoords.y)) - controller.continue_darken_stroke(cx, cy) + loupeView.controllerRef.continue_darken_stroke(cx, cy) return } - if (uiState && uiState.isCropping && isCropDragging) { + if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping && isCropDragging) { if (cropDragMode === "new") { // Update crop rectangle while dragging updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y, true) @@ -884,7 +904,7 @@ Item { cropRotation = newRotation // Update rotation in backend live (throttled) - if (controller) { + if (loupeView.controllerRef) { pendingRotation = cropRotation pendingAspect = -1 @@ -935,7 +955,7 @@ Item { bottom = constrainedBox[3] } - uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + loupeView.uiStateRef.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] } return } @@ -954,13 +974,13 @@ Item { globalPos.x > loupeView.width || globalPos.y > loupeView.height) { // Mouse is outside window - initiate drag-and-drop isDraggingOutside = true - if (controller) controller.start_drag_current_image() + if (loupeView.controllerRef) loupeView.controllerRef.start_drag_current_image() return } } // Normal pan behavior (only when not cropping) - if (!uiState || !uiState.isCropping) { + if (!loupeView.uiStateRef || !loupeView.uiStateRef.isCropping) { panTransform.x += (mouse.x - lastX) panTransform.y += (mouse.y - lastY) lastX = mouse.x @@ -973,12 +993,12 @@ Item { // Darken painting release if (isDarkenPainting) { isDarkenPainting = false - if (controller) controller.finish_darken_stroke() + if (loupeView.controllerRef) loupeView.controllerRef.finish_darken_stroke() return } isDraggingOutside = false - if (uiState && uiState.isCropping && isCropDragging) { + if (loupeView.uiStateRef && loupeView.uiStateRef.isCropping && isCropDragging) { endCropInteraction() } } @@ -1046,7 +1066,7 @@ Item { } function updateCropBox(x1, y1, x2, y2, applyAspectRatio = false) { - if (!uiState || !mainImage.source) return + if (!loupeView.uiStateRef || !mainImage.source) return var imgCoord1 = mapToImageCoordinates(Qt.point(x1, y1)) var imgCoord2 = mapToImageCoordinates(Qt.point(x2, y2)) @@ -1098,7 +1118,7 @@ Item { } } - uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + loupeView.uiStateRef.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] } function getAspectRatio(name) { @@ -1112,7 +1132,7 @@ Item { } function applyAspectRatioConstraint(left, top, right, bottom, dragMode) { - if (uiState.currentAspectRatioIndex <= 0 || !uiState.aspectRatioNames || uiState.aspectRatioNames.length <= uiState.currentAspectRatioIndex) { + if (loupeView.uiStateRef.currentAspectRatioIndex <= 0 || !loupeView.uiStateRef.aspectRatioNames || loupeView.uiStateRef.aspectRatioNames.length <= loupeView.uiStateRef.currentAspectRatioIndex) { // No aspect ratio, just clamp to bounds return [ Math.max(0, Math.min(1000, left)), @@ -1122,7 +1142,7 @@ Item { ]; } - var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; + var ratioName = loupeView.uiStateRef.aspectRatioNames[loupeView.uiStateRef.currentAspectRatioIndex]; var ratioPair = getAspectRatio(ratioName); if (!ratioPair || !mainImage || !imageRotator.width || !imageRotator.height) { return [left, top, right, bottom]; @@ -1297,8 +1317,8 @@ Item { } function updateCropBoxFromAspectRatio() { - if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return - var box = uiState.currentCropBox + if (!loupeView.uiStateRef || !loupeView.uiStateRef.currentCropBox || loupeView.uiStateRef.currentCropBox.length !== 4) return + var box = loupeView.uiStateRef.currentCropBox // Start with center of current box var cx = (box[0] + box[2]) / 2 @@ -1310,11 +1330,11 @@ Item { cy = 500 } - var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; + var ratioName = loupeView.uiStateRef.aspectRatioNames[loupeView.uiStateRef.currentAspectRatioIndex]; var ratioPair = getAspectRatio(ratioName); if (!ratioPair) { // Freeform selected - uiState.currentCropBox = [0, 0, 1000, 1000] // Reset to full image + loupeView.uiStateRef.currentCropBox = [0, 0, 1000, 1000] // Reset to full image mainMouseArea.cropRotation = 0 // Also reset visual rotation mainMouseArea.isRotating = false mainMouseArea.cropDragMode = "none" @@ -1355,7 +1375,7 @@ Item { var top = cy - height / 2 var bottom = cy + height / 2 - uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + loupeView.uiStateRef.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] } } @@ -1364,7 +1384,7 @@ Item { // Aspect ratio selector window (upper left corner) Rectangle { id: aspectRatioWindow - visible: uiState && uiState.isCropping + visible: loupeView.uiStateRef && loupeView.uiStateRef.isCropping anchors.top: parent.top anchors.left: parent.left anchors.margins: 10 @@ -1376,8 +1396,7 @@ Item { radius: 4 z: 1000 - // Try to get root from parent hierarchy - property bool isDark: typeof root !== "undefined" && root ? root.isDarkTheme : true + property bool isDark: loupeView.isDarkTheme Component.onCompleted: { // Update colors based on theme @@ -1397,19 +1416,21 @@ Item { } Repeater { - model: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames.length : 0 + model: loupeView.uiStateRef && loupeView.uiStateRef.aspectRatioNames ? loupeView.uiStateRef.aspectRatioNames.length : 0 Rectangle { + id: aspectRatioOption + required property int index width: parent.width height: 30 - color: uiState && uiState.currentAspectRatioIndex === index ? "#555555" : "transparent" + color: loupeView.uiStateRef && loupeView.uiStateRef.currentAspectRatioIndex === aspectRatioOption.index ? "#555555" : "transparent" radius: 3 Text { anchors.left: parent.left anchors.leftMargin: 10 anchors.verticalCenter: parent.verticalCenter - text: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames[index] : "" + text: loupeView.uiStateRef && loupeView.uiStateRef.aspectRatioNames ? loupeView.uiStateRef.aspectRatioNames[aspectRatioOption.index] : "" color: aspectRatioWindow.isDark ? "white" : "black" font.pixelSize: 11 } @@ -1417,10 +1438,10 @@ Item { MouseArea { anchors.fill: parent onClicked: { - if (uiState) { - uiState.currentAspectRatioIndex = index + if (loupeView.uiStateRef) { + loupeView.uiStateRef.currentAspectRatioIndex = aspectRatioOption.index // Re-apply aspect ratio to current crop box - if (uiState.currentCropBox && uiState.currentCropBox.length === 4) { + if (loupeView.uiStateRef.currentCropBox && loupeView.uiStateRef.currentCropBox.length === 4) { mainMouseArea.updateCropBoxFromAspectRatio() } } diff --git a/faststack/qml/DarkenToolPanel.qml b/faststack/qml/DarkenToolPanel.qml index c305c7e..b44aa3f 100644 --- a/faststack/qml/DarkenToolPanel.qml +++ b/faststack/qml/DarkenToolPanel.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Controls.Material 2.15 @@ -9,7 +11,9 @@ Window { width: 380 height: 700 title: "Background Darkening" - visible: uiState ? uiState.isDarkening : false + property var uiStateRef: typeof uiState !== "undefined" ? uiState : null + property var controllerRef: typeof controller !== "undefined" ? controller : null + visible: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.isDarkening : false flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint property color backgroundColor: "#1e1e1e" @@ -27,14 +31,14 @@ Window { color: backgroundColor onClosing: (close) => { - if (controller) controller.toggle_darken_mode() + if (darkenPanel.controllerRef) darkenPanel.controllerRef.toggle_darken_mode() } Shortcut { sequence: "Escape" context: Qt.WindowShortcut onActivated: { - if (controller) controller.toggle_darken_mode() + if (darkenPanel.controllerRef) darkenPanel.controllerRef.toggle_darken_mode() } } @@ -67,7 +71,7 @@ Window { target: modeCombo property: "currentIndex" value: { - var m = uiState ? uiState.darkenMode : "assisted" + var m = darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenMode : "assisted" if (m === "paint_only") return 1 if (m === "strong_subject") return 2 if (m === "border_auto") return 3 @@ -76,13 +80,13 @@ Window { } onActivated: (index) => { var modes = ["assisted", "paint_only", "strong_subject", "border_auto"] - if (controller) controller.set_darken_mode(modes[index]) + if (darkenPanel.controllerRef) darkenPanel.controllerRef.set_darken_mode(modes[index]) } ToolTip.visible: hovered ToolTip.delay: 500 ToolTip.text: { - var m = uiState ? uiState.darkenMode : "assisted" + var m = darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenMode : "assisted" if (m === "paint_only") return "Paint Only: Only your brush strokes define the mask.\nNo automatic detection — full manual control.\nBest for precise, targeted darkening." if (m === "strong_subject") @@ -98,7 +102,7 @@ Window { Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 4 - height: 1 + Layout.preferredHeight: 1 color: darkenPanel.separatorColor } @@ -115,19 +119,19 @@ Window { DarkenSlider { label: "Amount" paramKey: "darken_amount" - value: uiState ? uiState.darkenAmount * 100 : 50 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenAmount * 100 : 50 tooltip: "How much to darken the masked background areas.\n0 = no darkening, 100 = maximum darkening.\nStart around 30–50 and adjust to taste." } DarkenSlider { label: "Edge Protection" paramKey: "edge_protection" - value: uiState ? uiState.darkenEdgeProtection * 100 : 50 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenEdgeProtection * 100 : 50 tooltip: "Prevents darkening near strong edges (subject outlines).\nHigher values keep a brighter halo around sharp\nedges, avoiding unnatural dark fringing.\nUseful when the mask bleeds into the subject." } DarkenSlider { label: "Subject Protection" paramKey: "subject_protection" - value: uiState ? uiState.darkenSubjectProtection * 100 : 50 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenSubjectProtection * 100 : 50 tooltip: "Protects bright, saturated areas from darkening.\nHigher values preserve subject colors and highlights.\nHelps when the mask accidentally covers the subject." } @@ -136,7 +140,7 @@ Window { Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 4 - height: 1 + Layout.preferredHeight: 1 color: darkenPanel.separatorColor } @@ -153,32 +157,32 @@ Window { DarkenSlider { label: "Feather" paramKey: "feather" - value: uiState ? uiState.darkenFeather * 100 : 50 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenFeather * 100 : 50 tooltip: "Softens the mask edges for a gradual transition.\n0 = hard edge (sharp boundary between dark and light),\n100 = very soft edge (wide gradient).\nHigher values give a more natural, blended look." } DarkenSlider { label: "Dark Range" paramKey: "dark_range" - value: uiState ? uiState.darkenDarkRange * 100 : 50 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenDarkRange * 100 : 50 tooltip: "Controls how the mask interacts with already-dark areas.\nHigher values extend the mask into darker tones,\nlower values focus darkening on midtones and highlights.\nUseful for controlling shadow depth." } DarkenSlider { label: "Neutrality" paramKey: "neutrality_sensitivity" - value: uiState ? uiState.darkenNeutrality * 100 : 50 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenNeutrality * 100 : 50 tooltip: "Sensitivity to neutral (grey/unsaturated) colors.\nHigher values cause the mask to prefer darkening\nneutral areas while leaving colorful areas alone.\nHelps isolate plain backgrounds from colorful subjects." } DarkenSlider { label: "Expand / Contract" paramKey: "expand_contract" minVal: -100 - value: uiState ? uiState.darkenExpandContract * 100 : 0 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenExpandContract * 100 : 0 tooltip: "Grows or shrinks the mask boundary.\nPositive values expand the darkened area outward,\nnegative values contract it inward.\nUse to fine-tune where darkening starts and stops." } DarkenSlider { label: "Auto From Edges" paramKey: "auto_from_edges" - value: uiState ? uiState.darkenAutoEdges * 100 : 0 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenAutoEdges * 100 : 0 minVal: 0 tooltip: "Uses edge detection to guide automatic masking.\nSmooth areas between strong edges get higher\nbackground confidence, helping the mask follow\nsubject outlines. Complements Edge Protection:\nthat slider stops the mask at edges, this one\nactively uses edges to shape the mask." } @@ -188,7 +192,7 @@ Window { Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 4 - height: 1 + Layout.preferredHeight: 1 color: darkenPanel.separatorColor } @@ -222,12 +226,12 @@ Window { from: 1; to: 100; stepSize: 1 Binding on value { - value: uiState ? uiState.darkenBrushRadius * 1000 : 30 + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenBrushRadius * 1000 : 30 when: !brushSlider.pressed } onMoved: { - if (controller) controller.set_darken_param("brush_radius", value / 1000.0) + if (darkenPanel.controllerRef) darkenPanel.controllerRef.set_darken_param("brush_radius", value / 1000.0) } } Label { @@ -253,7 +257,7 @@ Window { Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 4 - height: 1 + Layout.preferredHeight: 1 color: darkenPanel.separatorColor } @@ -277,12 +281,12 @@ Window { Binding { target: overlayCheck property: "checked" - value: uiState ? uiState.darkenOverlayVisible : true + value: darkenPanel.uiStateRef ? darkenPanel.uiStateRef.darkenOverlayVisible : true when: !overlayCheck.pressed } onToggled: { - if (controller) controller.set_darken_overlay_visible(checked) + if (darkenPanel.controllerRef) darkenPanel.controllerRef.set_darken_overlay_visible(checked) } Material.accent: darkenPanel.accentColor @@ -316,18 +320,20 @@ Window { {"name": "Cyan", "r": 80, "g": 255, "b": 255} ] Rectangle { + id: overlaySwatch + required property var modelData width: 24; height: 24; radius: 4 - color: Qt.rgba(modelData.r / 255, modelData.g / 255, modelData.b / 255, 1.0) + color: Qt.rgba(overlaySwatch.modelData.r / 255, overlaySwatch.modelData.g / 255, overlaySwatch.modelData.b / 255, 1.0) border.color: activeFocus ? "white" : "transparent" border.width: 2 activeFocusOnTab: true - Accessible.name: modelData.name + Accessible.name: overlaySwatch.modelData.name Accessible.role: Accessible.Button ToolTip.visible: swatchMA.containsMouse ToolTip.delay: 500 - ToolTip.text: modelData.name + ToolTip.text: overlaySwatch.modelData.name MouseArea { id: swatchMA @@ -335,13 +341,13 @@ Window { cursorShape: Qt.PointingHandCursor hoverEnabled: true onClicked: { - if (controller) controller.set_darken_overlay_color(modelData.r, modelData.g, modelData.b) + if (darkenPanel.controllerRef) darkenPanel.controllerRef.set_darken_overlay_color(overlaySwatch.modelData.r, overlaySwatch.modelData.g, overlaySwatch.modelData.b) } } Keys.onPressed: (event) => { if (event.key === Qt.Key_Enter || event.key === Qt.Key_Return || event.key === Qt.Key_Space) { - if (controller) controller.set_darken_overlay_color(modelData.r, modelData.g, modelData.b) + if (darkenPanel.controllerRef) darkenPanel.controllerRef.set_darken_overlay_color(overlaySwatch.modelData.r, overlaySwatch.modelData.g, overlaySwatch.modelData.b) event.accepted = true } } @@ -354,7 +360,7 @@ Window { Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 4 - height: 1 + Layout.preferredHeight: 1 color: darkenPanel.separatorColor } @@ -364,11 +370,12 @@ Window { spacing: 10 Button { + id: undoStrokeButton text: "Undo Stroke" Layout.fillWidth: true - onClicked: { if (controller) controller.undo_darken_stroke() } - contentItem: Text { text: parent.text; font: parent.font; color: darkenPanel.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.pressed ? "#40ffffff" : "#20ffffff"; radius: 4; border.color: parent.hovered ? "#60ffffff" : "transparent" } + onClicked: { if (darkenPanel.controllerRef) darkenPanel.controllerRef.undo_darken_stroke() } + contentItem: Text { text: undoStrokeButton.text; font: undoStrokeButton.font; color: darkenPanel.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: undoStrokeButton.down ? "#40ffffff" : "#20ffffff"; radius: 4; border.color: undoStrokeButton.hovered ? "#60ffffff" : "transparent" } ToolTip.visible: hovered ToolTip.delay: 500 @@ -376,11 +383,12 @@ Window { } Button { + id: clearAllButton text: "Clear All" Layout.fillWidth: true - onClicked: { if (controller) controller.clear_darken_strokes() } - contentItem: Text { text: parent.text; font: parent.font; color: darkenPanel.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.pressed ? "#40ffffff" : "#20ffffff"; radius: 4; border.color: parent.hovered ? "#60ffffff" : "transparent" } + onClicked: { if (darkenPanel.controllerRef) darkenPanel.controllerRef.clear_darken_strokes() } + contentItem: Text { text: clearAllButton.text; font: clearAllButton.font; color: darkenPanel.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: clearAllButton.down ? "#40ffffff" : "#20ffffff"; radius: 4; border.color: clearAllButton.hovered ? "#60ffffff" : "transparent" } ToolTip.visible: hovered ToolTip.delay: 500 @@ -390,12 +398,13 @@ Window { // --- Close Button --- Button { + id: closeDarkenButton Layout.fillWidth: true Layout.topMargin: 6 text: "Close (K)" - onClicked: { if (controller) controller.toggle_darken_mode() } - contentItem: Text { text: parent.text; font: parent.font; color: darkenPanel.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.pressed ? "#40ffffff" : "#20ffffff"; radius: 4; border.color: parent.hovered ? darkenPanel.accentColor : "#60ffffff" } + onClicked: { if (darkenPanel.controllerRef) darkenPanel.controllerRef.toggle_darken_mode() } + contentItem: Text { text: closeDarkenButton.text; font: closeDarkenButton.font; color: darkenPanel.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: closeDarkenButton.down ? "#40ffffff" : "#20ffffff"; radius: 4; border.color: closeDarkenButton.hovered ? darkenPanel.accentColor : "#60ffffff" } ToolTip.visible: hovered ToolTip.delay: 500 @@ -455,7 +464,7 @@ Window { repeat: true onTriggered: { if (Math.abs(dSlider._pendingValue - dSlider._lastSentValue) > 0.001) { - if (controller) controller.set_darken_param(sliderRoot.paramKey, dSlider._pendingValue / sliderRoot.maxVal) + if (darkenPanel.controllerRef) darkenPanel.controllerRef.set_darken_param(sliderRoot.paramKey, dSlider._pendingValue / sliderRoot.maxVal) dSlider._lastSentValue = dSlider._pendingValue } } @@ -468,7 +477,7 @@ Window { if (!dsendTimer.running) dsendTimer.start() } else { dsendTimer.stop() - if (controller) controller.set_darken_param(sliderRoot.paramKey, value / sliderRoot.maxVal) + if (darkenPanel.controllerRef) darkenPanel.controllerRef.set_darken_param(sliderRoot.paramKey, value / sliderRoot.maxVal) } } onMoved: { diff --git a/faststack/qml/DeleteBatchDialog.qml b/faststack/qml/DeleteBatchDialog.qml index 68ec371..a3ac0f1 100644 --- a/faststack/qml/DeleteBatchDialog.qml +++ b/faststack/qml/DeleteBatchDialog.qml @@ -14,6 +14,7 @@ Dialog { property int batchCount: 0 property color backgroundColor: "#1e1e1e" property color textColor: "white" + property var controllerRef: typeof controller !== "undefined" ? controller : null background: Rectangle { color: deleteBatchDialog.backgroundColor @@ -27,7 +28,7 @@ Dialog { padding: 20 Label { - text: `You have ${batchCount} image${batchCount === 1 ? '' : 's'} selected in a batch.` + text: `You have ${deleteBatchDialog.batchCount} image${deleteBatchDialog.batchCount === 1 ? '' : 's'} selected in a batch.` wrapMode: Text.WordWrap width: parent.width - parent.padding * 2 color: deleteBatchDialog.textColor @@ -47,19 +48,20 @@ Dialog { anchors.horizontalCenter: parent.horizontalCenter Button { + id: deleteCurrentButton text: "Delete Current Image" onClicked: { deleteBatchDialog.close() - if (controller) { - controller.delete_current_image_only() + if (deleteBatchDialog.controllerRef) { + deleteBatchDialog.controllerRef.delete_current_image_only() } } background: Rectangle { - color: parent.pressed ? "#555555" : (parent.hovered ? "#666666" : "#444444") + color: deleteCurrentButton.down ? "#555555" : (deleteCurrentButton.hovered ? "#666666" : "#444444") radius: 4 } contentItem: Text { - text: parent.text + text: deleteCurrentButton.text color: deleteBatchDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -67,19 +69,20 @@ Dialog { } Button { - text: `Delete All (${batchCount})` + id: deleteAllButton + text: `Delete All (${deleteBatchDialog.batchCount})` onClicked: { deleteBatchDialog.close() - if (controller) { - controller.delete_batch_images() + if (deleteBatchDialog.controllerRef) { + deleteBatchDialog.controllerRef.delete_batch_images() } } background: Rectangle { - color: parent.pressed ? "#cc0000" : (parent.hovered ? "#ff0000" : "#aa0000") + color: deleteAllButton.down ? "#cc0000" : (deleteAllButton.hovered ? "#ff0000" : "#aa0000") radius: 4 } contentItem: Text { - text: parent.text + text: deleteAllButton.text color: deleteBatchDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -88,16 +91,17 @@ Dialog { } Button { + id: cancelDeleteButton text: "Cancel" onClicked: { deleteBatchDialog.close() } background: Rectangle { - color: parent.pressed ? "#555555" : (parent.hovered ? "#666666" : "#444444") + color: cancelDeleteButton.down ? "#555555" : (cancelDeleteButton.hovered ? "#666666" : "#444444") radius: 4 } contentItem: Text { - text: parent.text + text: cancelDeleteButton.text color: deleteBatchDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -108,15 +112,15 @@ Dialog { onOpened: { // Notify Python that a dialog is open - if (controller) { - controller.dialog_opened() + if (deleteBatchDialog.controllerRef) { + deleteBatchDialog.controllerRef.dialog_opened() } } onClosed: { // Notify Python that dialog is closed - if (controller) { - controller.dialog_closed() + if (deleteBatchDialog.controllerRef) { + deleteBatchDialog.controllerRef.dialog_closed() } } } diff --git a/faststack/qml/ExifDialog.qml b/faststack/qml/ExifDialog.qml index 7f8dd27..c00c30a 100644 --- a/faststack/qml/ExifDialog.qml +++ b/faststack/qml/ExifDialog.qml @@ -18,6 +18,7 @@ Dialog { // Theme properties (can be bound from Main.qml) property color backgroundColor: "#333333" property color textColor: "#ffffff" + property var controllerRef: typeof controller !== "undefined" ? controller : null background: Rectangle { color: exifDialog.backgroundColor @@ -29,14 +30,14 @@ Dialog { // Reset to summary view when opened showFull = false // Notify Python that a dialog is open - if (controller) { - controller.dialog_opened() + if (exifDialog.controllerRef) { + exifDialog.controllerRef.dialog_opened() } } onClosed: { - if (controller) { - controller.dialog_closed() + if (exifDialog.controllerRef) { + exifDialog.controllerRef.dialog_closed() } } diff --git a/faststack/qml/FilterDialog.qml b/faststack/qml/FilterDialog.qml index a27e813..a54c83e 100644 --- a/faststack/qml/FilterDialog.qml +++ b/faststack/qml/FilterDialog.qml @@ -15,6 +15,7 @@ Dialog { property var filterFlags: [] property color backgroundColor: "#1e1e1e" property color textColor: "white" + property var controllerRef: typeof controller !== "undefined" ? controller : null // Match the app's theme dynamically @@ -84,7 +85,7 @@ Dialog { checked: false Material.foreground: filterDialog.textColor Material.accent: "#4fc3f7" - onCheckedChanged: _collectFlags() + onCheckedChanged: filterDialog._collectFlags() } CheckBox { id: cbStacked @@ -92,7 +93,7 @@ Dialog { checked: false Material.foreground: filterDialog.textColor Material.accent: "#81c784" - onCheckedChanged: _collectFlags() + onCheckedChanged: filterDialog._collectFlags() } CheckBox { id: cbEdited @@ -100,7 +101,7 @@ Dialog { checked: false Material.foreground: filterDialog.textColor Material.accent: "#ffb74d" - onCheckedChanged: _collectFlags() + onCheckedChanged: filterDialog._collectFlags() } CheckBox { id: cbRestacked @@ -108,7 +109,7 @@ Dialog { checked: false Material.foreground: filterDialog.textColor Material.accent: "#ce93d8" - onCheckedChanged: _collectFlags() + onCheckedChanged: filterDialog._collectFlags() } CheckBox { id: cbTodo @@ -116,7 +117,7 @@ Dialog { checked: false Material.foreground: filterDialog.textColor Material.accent: "#64B5F6" - onCheckedChanged: _collectFlags() + onCheckedChanged: filterDialog._collectFlags() } CheckBox { id: cbFavorite @@ -124,7 +125,7 @@ Dialog { checked: false Material.foreground: filterDialog.textColor Material.accent: "#ffd54f" - onCheckedChanged: _collectFlags() + onCheckedChanged: filterDialog._collectFlags() } } @@ -155,12 +156,12 @@ Dialog { onOpened: { // Load current filter string from controller - var current = controller && controller.get_filter_string ? controller.get_filter_string() : "" + var current = filterDialog.controllerRef && filterDialog.controllerRef.get_filter_string ? filterDialog.controllerRef.get_filter_string() : "" filterDialog.filterString = current || "" filterField.text = filterDialog.filterString // Load current filter flags from controller - var currentFlags = controller && controller.get_filter_flags ? controller.get_filter_flags() : [] + var currentFlags = filterDialog.controllerRef && filterDialog.controllerRef.get_filter_flags ? filterDialog.controllerRef.get_filter_flags() : [] cbUploaded.checked = currentFlags.indexOf("uploaded") >= 0 cbStacked.checked = currentFlags.indexOf("stacked") >= 0 cbEdited.checked = currentFlags.indexOf("edited") >= 0 @@ -171,15 +172,15 @@ Dialog { filterField.forceActiveFocus() filterField.selectAll() // Notify Python that a dialog is open - if (controller && controller.dialog_opened) { - controller.dialog_opened() + if (filterDialog.controllerRef && filterDialog.controllerRef.dialog_opened) { + filterDialog.controllerRef.dialog_opened() } } onClosed: { // Notify Python that dialog is closed - if (controller && controller.dialog_closed) { - controller.dialog_closed() + if (filterDialog.controllerRef && filterDialog.controllerRef.dialog_closed) { + filterDialog.controllerRef.dialog_closed() } } } diff --git a/faststack/qml/HistogramWindow.qml b/faststack/qml/HistogramWindow.qml index 2829434..2a7d6e3 100644 --- a/faststack/qml/HistogramWindow.qml +++ b/faststack/qml/HistogramWindow.qml @@ -1,6 +1,5 @@ import QtQuick import QtQuick.Window -import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Window { @@ -10,7 +9,9 @@ Window { height: 450 minimumWidth: 100 minimumHeight: 50 - visible: uiState ? uiState.isHistogramVisible : false + property var uiStateRef: typeof uiState !== "undefined" ? uiState : null + property var controllerRef: typeof controller !== "undefined" ? controller : null + visible: histogramWindow.uiStateRef ? histogramWindow.uiStateRef.isHistogramVisible : false FocusScope { id: histogramKeyScope @@ -18,30 +19,30 @@ Window { focus: histogramWindow.visible Keys.onPressed: function(event) { - if (event.key === Qt.Key_H && controller) { - controller.toggle_histogram() + if (event.key === Qt.Key_H && histogramWindow.controllerRef) { + histogramWindow.controllerRef.toggle_histogram() event.accepted = true - } else if (controller) { + } else if (histogramWindow.controllerRef) { // Forward unhandled keys (e.g. arrow keys) to controller - controller.handle_key_from_histogram(event.key, event.modifiers, event.text) + histogramWindow.controllerRef.handle_key_from_histogram(event.key, event.modifiers, event.text) event.accepted = true } } } Connections { - target: uiState + target: histogramWindow.uiStateRef function onCurrentImageSourceChanged() { - if (histogramWindow.visible && controller) { - controller.update_histogram() + if (histogramWindow.visible && histogramWindow.controllerRef) { + histogramWindow.controllerRef.update_histogram() } } } onVisibleChanged: { - if (visible && controller) { + if (visible && histogramWindow.controllerRef) { histogramKeyScope.forceActiveFocus() - controller.update_histogram() + histogramWindow.controllerRef.update_histogram() } } @@ -68,9 +69,9 @@ Window { dangerColor: histogramWindow.dangerColor textColor: histogramWindow.primaryTextColor - histogramData: uiState && uiState.histogramData ? (uiState.histogramData["r"] || []) : [] - clipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_clip"] || 0) : 0 - preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_preclip"] || 0) : 0 + histogramData: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["r"] || []) : [] + clipCount: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["r_clip"] || 0) : 0 + preClipCount: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["r_preclip"] || 0) : 0 } SingleChannelHistogram { @@ -83,9 +84,9 @@ Window { dangerColor: histogramWindow.dangerColor textColor: histogramWindow.primaryTextColor - histogramData: uiState && uiState.histogramData ? (uiState.histogramData["g"] || []) : [] - clipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_clip"] || 0) : 0 - preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_preclip"] || 0) : 0 + histogramData: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["g"] || []) : [] + clipCount: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["g_clip"] || 0) : 0 + preClipCount: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["g_preclip"] || 0) : 0 } SingleChannelHistogram { @@ -98,9 +99,9 @@ Window { dangerColor: histogramWindow.dangerColor textColor: histogramWindow.primaryTextColor - histogramData: uiState && uiState.histogramData ? (uiState.histogramData["b"] || []) : [] - clipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_clip"] || 0) : 0 - preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_preclip"] || 0) : 0 + histogramData: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["b"] || []) : [] + clipCount: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["b_clip"] || 0) : 0 + preClipCount: histogramWindow.uiStateRef && histogramWindow.uiStateRef.histogramData ? (histogramWindow.uiStateRef.histogramData["b_preclip"] || 0) : 0 } } } diff --git a/faststack/qml/ImageEditorDialog.qml b/faststack/qml/ImageEditorDialog.qml index 586018e..005f617 100644 --- a/faststack/qml/ImageEditorDialog.qml +++ b/faststack/qml/ImageEditorDialog.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Controls.Material 2.15 @@ -8,8 +10,10 @@ Window { id: imageEditorDialog width: 800 height: 820 - title: uiState && uiState.editorFilename ? "Image Editor - " + uiState.editorFilename + " (" + uiState.editorBitDepth + "-bit)" : "Image Editor" - visible: uiState ? uiState.isEditorOpen : false + property var uiStateRef: null + property var controllerRef: null + title: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.editorFilename ? "Image Editor - " + imageEditorDialog.uiStateRef.editorFilename + " (" + imageEditorDialog.uiStateRef.editorBitDepth + "-bit)" : "Image Editor" + visible: imageEditorDialog.uiStateRef ? imageEditorDialog.uiStateRef.isEditorOpen : false flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint property int updatePulse: 0 property color backgroundColor: "#1e1e1e" // Default dark background @@ -23,34 +27,39 @@ Window { readonly property color controlBorder: "#30ffffff" readonly property color separatorColor: "#20ffffff" - Material.theme: (uiState && uiState.theme === 0) ? Material.Dark : Material.Light + Component.onCompleted: { + imageEditorDialog.uiStateRef = uiState + imageEditorDialog.controllerRef = controller + } + + Material.theme: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.theme === 0) ? Material.Dark : Material.Light Material.accent: accentColor onClosing: (close) => { - uiState.isEditorOpen = false + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false } onVisibleChanged: { - if (visible) { - if (controller) controller.update_histogram() + if (visible && imageEditorDialog.controllerRef) { + imageEditorDialog.controllerRef.update_histogram() } } // Auto-update histogram when pulse changes (buttons, double-taps, spinbox) onUpdatePulseChanged: { - if (visible && controller) { - controller.update_histogram() + if (visible && imageEditorDialog.controllerRef) { + imageEditorDialog.controllerRef.update_histogram() } } property int slidersPressedCount: 0 onSlidersPressedCountChanged: { - uiState.setAnySliderPressed(slidersPressedCount > 0) + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.setAnySliderPressed(slidersPressedCount > 0) } function getBackendValue(key) { var _dependency = updatePulse; - if (uiState && key in uiState) return uiState[key]; + if (imageEditorDialog.uiStateRef && key in imageEditorDialog.uiStateRef) return imageEditorDialog.uiStateRef[key]; return 0.0; } @@ -61,15 +70,15 @@ Window { sequence: "Escape" context: Qt.WindowShortcut onActivated: { - uiState.isEditorOpen = false + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false } } Shortcut { sequence: "S" context: Qt.WindowShortcut - enabled: uiState ? !uiState.isSaving : true + enabled: imageEditorDialog.uiStateRef ? !imageEditorDialog.uiStateRef.isSaving : true onActivated: { - controller.save_edited_image() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.save_edited_image() // Note: Editor closes automatically via _on_save_finished callback } } @@ -77,7 +86,7 @@ Window { sequence: "K" context: Qt.WindowShortcut onActivated: { - if (controller) controller.toggle_darken_mode() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.toggle_darken_mode() } } @@ -132,13 +141,13 @@ Window { } ListModel { id: lightModel - ListElement { name: "Exposure"; key: "exposure" } - ListElement { name: "Brightness"; key: "brightness" } - ListElement { name: "Highlights"; key: "highlights" } - ListElement { name: "Whites"; key: "whites" } - ListElement { name: "Shadows"; key: "shadows" } - ListElement { name: "Blacks"; key: "blacks" } - ListElement { name: "Contrast"; key: "contrast" } + ListElement { name: "Exposure"; key: "exposure"; reverse: false; min: -100; max: 100 } + ListElement { name: "Brightness"; key: "brightness"; reverse: false; min: -100; max: 100 } + ListElement { name: "Highlights"; key: "highlights"; reverse: false; min: -100; max: 100 } + ListElement { name: "Whites"; key: "whites"; reverse: false; min: -100; max: 100 } + ListElement { name: "Shadows"; key: "shadows"; reverse: false; min: -100; max: 100 } + ListElement { name: "Blacks"; key: "blacks"; reverse: false; min: -100; max: 100 } + ListElement { name: "Contrast"; key: "contrast"; reverse: false; min: -100; max: 100 } } Repeater { model: lightModel; delegate: editSlider } @@ -151,9 +160,9 @@ Window { } ListModel { id: detailModel - ListElement { name: "Clarity"; key: "clarity" } - ListElement { name: "Texture"; key: "texture" } - ListElement { name: "Sharpness"; key: "sharpness" } + ListElement { name: "Clarity"; key: "clarity"; reverse: false; min: -100; max: 100 } + ListElement { name: "Texture"; key: "texture"; reverse: false; min: -100; max: 100 } + ListElement { name: "Sharpness"; key: "sharpness"; reverse: false; min: -100; max: 100 } } Repeater { model: detailModel; delegate: editSlider } @@ -175,9 +184,9 @@ Window { textColor: imageEditorDialog.textColor minimal: false - histogramData: uiState && uiState.histogramData ? (uiState.histogramData["r"] || []) : [] - clipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_clip"] || 0) : 0 - preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["r_preclip"] || 0) : 0 + histogramData: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["r"] || []) : [] + clipCount: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["r_clip"] || 0) : 0 + preClipCount: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["r_preclip"] || 0) : 0 } SingleChannelHistogram { @@ -191,9 +200,9 @@ Window { textColor: imageEditorDialog.textColor minimal: false - histogramData: uiState && uiState.histogramData ? (uiState.histogramData["g"] || []) : [] - clipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_clip"] || 0) : 0 - preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["g_preclip"] || 0) : 0 + histogramData: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["g"] || []) : [] + clipCount: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["g_clip"] || 0) : 0 + preClipCount: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["g_preclip"] || 0) : 0 } SingleChannelHistogram { @@ -207,9 +216,9 @@ Window { textColor: imageEditorDialog.textColor minimal: false - histogramData: uiState && uiState.histogramData ? (uiState.histogramData["b"] || []) : [] - clipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_clip"] || 0) : 0 - preClipCount: uiState && uiState.histogramData ? (uiState.histogramData["b_preclip"] || 0) : 0 + histogramData: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["b"] || []) : [] + clipCount: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["b_clip"] || 0) : 0 + preClipCount: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.histogramData ? (imageEditorDialog.uiStateRef.histogramData["b_preclip"] || 0) : 0 } } @@ -220,17 +229,17 @@ Window { spacing: 15 Label { - visible: (uiState && uiState.highlightState && uiState.highlightState.headroom_pct > 0.001) - text: "📈 Headroom: " + (uiState && uiState.highlightState ? (uiState.highlightState.headroom_pct * 100).toFixed(1) : "0.0") + "%" + visible: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.highlightState && imageEditorDialog.uiStateRef.highlightState.headroom_pct > 0.001) + text: "📈 Headroom: " + (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.highlightState ? (imageEditorDialog.uiStateRef.highlightState.headroom_pct * 100).toFixed(1) : "0.0") + "%" font.pixelSize: 10 color: "#50e150" // Green - good, recoverable opacity: 0.8 } Label { - visible: (uiState && uiState.highlightState && uiState.highlightState.source_clipped_pct > 0.01) - text: "⚠ Clipped: " + (uiState && uiState.highlightState ? (uiState.highlightState.source_clipped_pct * 100).toFixed(1) : "0.0") + "%" + visible: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.highlightState && imageEditorDialog.uiStateRef.highlightState.source_clipped_pct > 0.01) + text: "⚠ Clipped: " + (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.highlightState ? (imageEditorDialog.uiStateRef.highlightState.source_clipped_pct * 100).toFixed(1) : "0.0") + "%" font.pixelSize: 10 - color: uiState && uiState.highlightState && uiState.highlightState.source_clipped_pct > 0.05 ? "#e15050" : "#e1a050" // Red if severe, orange if mild + color: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.highlightState && imageEditorDialog.uiStateRef.highlightState.source_clipped_pct > 0.05 ? "#e15050" : "#e1a050" // Red if severe, orange if mild opacity: 0.8 } Item { Layout.fillWidth: true } // Spacer @@ -249,20 +258,20 @@ Window { sourceComponent: sectionHeader Layout.topMargin: 0 // Remove top margin for the very first item onLoaded: item.text = "📸 Source" - visible: uiState ? uiState.hasRaw : false + visible: imageEditorDialog.uiStateRef ? imageEditorDialog.uiStateRef.hasRaw : false } Button { - text: (uiState && uiState.isRawActive) ? "RAW Loaded" : "Load RAW" + text: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.isRawActive) ? "RAW Loaded" : "Load RAW" Layout.fillWidth: true - visible: uiState ? uiState.hasRaw : false - enabled: uiState ? !uiState.isRawActive : false + visible: imageEditorDialog.uiStateRef ? imageEditorDialog.uiStateRef.hasRaw : false + enabled: imageEditorDialog.uiStateRef ? !imageEditorDialog.uiStateRef.isRawActive : false onClicked: { - if (uiState) uiState.enableRawEditing() + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.enableRawEditing() imageEditorDialog.updatePulse++ } } Label { - text: uiState ? uiState.saveBehaviorMessage : "" + text: imageEditorDialog.uiStateRef ? imageEditorDialog.uiStateRef.saveBehaviorMessage : "" Layout.fillWidth: true wrapMode: Text.WordWrap font.pixelSize: 11 @@ -272,21 +281,21 @@ Window { } Loader { sourceComponent: sectionSeparator - visible: uiState ? uiState.hasRaw : false + visible: imageEditorDialog.uiStateRef ? imageEditorDialog.uiStateRef.hasRaw : false } // --- Color Group --- Loader { sourceComponent: sectionHeader - Layout.topMargin: (uiState && uiState.hasRaw) ? 5 : 0 // Adjust logic if needed + Layout.topMargin: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.hasRaw) ? 5 : 0 // Adjust logic if needed onLoaded: item.text = "🎨 Color" } ListModel { id: colorModel - ListElement { name: "Saturation"; key: "saturation"; reverse: false } - ListElement { name: "Vibrance"; key: "vibrance"; reverse: false } - ListElement { name: "Temp (Blue/Yel)"; key: "white_balance_by"; reverse: false } - ListElement { name: "Tint (Grn/Mag)"; key: "white_balance_mg"; reverse: false } + ListElement { name: "Saturation"; key: "saturation"; reverse: false; min: -100; max: 100 } + ListElement { name: "Vibrance"; key: "vibrance"; reverse: false; min: -100; max: 100 } + ListElement { name: "Temp (Blue/Yel)"; key: "white_balance_by"; reverse: false; min: -100; max: 100 } + ListElement { name: "Tint (Grn/Mag)"; key: "white_balance_mg"; reverse: false; min: -100; max: 100 } } Repeater { model: colorModel; delegate: editSlider } @@ -294,20 +303,22 @@ Window { Layout.fillWidth: true spacing: 10 Button { + id: autoWbButton text: "Auto WB" Layout.fillWidth: true font.pixelSize: 12 onClicked: { - controller.auto_white_balance() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.auto_white_balance() imageEditorDialog.updatePulse++ } } Button { + id: autoLevelsButton text: "Auto Levels" Layout.fillWidth: true font.pixelSize: 12 onClicked: { - controller.auto_levels() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.auto_levels() imageEditorDialog.updatePulse++ } } @@ -322,28 +333,29 @@ Window { } ListModel { id: effectsModel - ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } + ListElement { name: "Vignette"; key: "vignette"; reverse: false; min: 0; max: 100 } } Repeater { model: effectsModel; delegate: editSlider } Button { + id: darkenModeButton text: "Darken Background (K)" Layout.fillWidth: true font.pixelSize: 12 onClicked: { - if (controller) controller.toggle_darken_mode() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.toggle_darken_mode() } contentItem: Text { - text: parent.text - font: parent.font - color: (uiState && uiState.isDarkening) ? "white" : imageEditorDialog.textColor + text: darkenModeButton.text + font: darkenModeButton.font + color: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.isDarkening) ? "white" : imageEditorDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: (uiState && uiState.isDarkening) ? imageEditorDialog.accentColor : (parent.pressed ? "#40ffffff" : "#20ffffff") + color: (imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.isDarkening) ? imageEditorDialog.accentColor : (darkenModeButton.down ? "#40ffffff" : "#20ffffff") radius: 4 - border.color: parent.hovered ? "#60ffffff" : "transparent" + border.color: darkenModeButton.hovered ? "#60ffffff" : "transparent" } } @@ -365,12 +377,12 @@ Window { Item { Layout.fillWidth: true } // Spacer Button { text: "↶ -90°" - onClicked: controller.rotate_image_ccw() + onClicked: { if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.rotate_image_ccw() } Layout.preferredWidth: 80 } Button { text: "↷ +90°" - onClicked: controller.rotate_image_cw() + onClicked: { if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.rotate_image_cw() } Layout.preferredWidth: 80 } } @@ -384,18 +396,19 @@ Window { // Reset (Tertiary) Button { + id: resetButton text: "Reset" flat: true Layout.preferredWidth: 80 Material.foreground: imageEditorDialog.textColor onClicked: { - controller.reset_edit_parameters() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.reset_edit_parameters() imageEditorDialog.updatePulse++ } background: Rectangle { - color: parent.pressed ? "#20ffffff" : "transparent" + color: resetButton.down ? "#20ffffff" : "transparent" radius: 4 - border.color: parent.hovered ? "#40ffffff" : "transparent" + border.color: resetButton.hovered ? "#40ffffff" : "transparent" } } @@ -403,39 +416,41 @@ Window { // Close (Secondary) Button { + id: closeEditorButton text: "Close" Layout.preferredWidth: 100 onClicked: { - uiState.isEditorOpen = false + if (imageEditorDialog.uiStateRef) imageEditorDialog.uiStateRef.isEditorOpen = false } contentItem: Text { - text: parent.text - font: parent.font - opacity: enabled ? 1.0 : 0.3 + text: closeEditorButton.text + font: closeEditorButton.font + opacity: closeEditorButton.enabled ? 1.0 : 0.3 color: imageEditorDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.pressed ? "#40ffffff" : "#20ffffff" + color: closeEditorButton.down ? "#40ffffff" : "#20ffffff" radius: 4 - border.color: parent.hovered ? "#60ffffff" : "transparent" + border.color: closeEditorButton.hovered ? "#60ffffff" : "transparent" } } // Save (Primary) Button { - text: uiState && uiState.isSaving ? "Saving..." : "Save" + id: saveEditorButton + text: imageEditorDialog.uiStateRef && imageEditorDialog.uiStateRef.isSaving ? "Saving..." : "Save" Layout.preferredWidth: 100 highlighted: true - enabled: uiState ? !uiState.isSaving : true + enabled: imageEditorDialog.uiStateRef ? !imageEditorDialog.uiStateRef.isSaving : true Material.background: imageEditorDialog.accentColor onClicked: { - controller.save_edited_image() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.save_edited_image() // Note: Editor closes automatically via _on_save_finished callback } background: Rectangle { - color: parent.enabled ? (parent.pressed ? Qt.darker(imageEditorDialog.accentColor, 1.1) : imageEditorDialog.accentColor) : Qt.darker(imageEditorDialog.accentColor, 1.5) + color: saveEditorButton.enabled ? (saveEditorButton.down ? Qt.darker(imageEditorDialog.accentColor, 1.1) : imageEditorDialog.accentColor) : Qt.darker(imageEditorDialog.accentColor, 1.5) radius: 4 // Subtle shadow simulation layer.enabled: true @@ -449,16 +464,23 @@ Window { Component { id: editSlider RowLayout { + id: sliderRow + required property string name + required property string key + required property bool reverse + required property real min + required property real max + Layout.fillWidth: true spacing: 15 - property bool isReversed: model.reverse !== undefined ? model.reverse : false - property real minVal: model.min === undefined ? -100 : model.min - property real maxVal: model.max === undefined ? 100 : model.max + property bool isReversed: reverse + property real minVal: min + property real maxVal: max // Label Text { - text: model.name + text: sliderRow.name color: imageEditorDialog.textColor font.pixelSize: 13 font.weight: Font.Medium @@ -472,13 +494,13 @@ Window { id: slider Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter - from: minVal - to: maxVal + from: sliderRow.minVal + to: sliderRow.maxVal stepSize: 1 property real backendValue: { - var val = imageEditorDialog.getBackendValue(model.key) * maxVal - return isReversed ? -val : val + var val = imageEditorDialog.getBackendValue(sliderRow.key) * sliderRow.maxVal + return sliderRow.isReversed ? -val : val } // Auto-sync visual slider with backend changes when not dragging @@ -497,8 +519,8 @@ Window { repeat: true onTriggered: { if (Math.abs(slider._pendingValue - slider._lastSentValue) > 0.001) { - var sendValue = isReversed ? -slider._pendingValue : slider._pendingValue - controller.set_edit_parameter(model.key, sendValue / maxVal) + var sendValue = sliderRow.isReversed ? -slider._pendingValue : slider._pendingValue + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, sendValue / sliderRow.maxVal) slider._lastSentValue = slider._pendingValue } } @@ -529,7 +551,7 @@ Window { function triggerReset() { slider.isResetting = true sendTimer.stop() - controller.set_edit_parameter(model.key, 0.0) + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, 0.0) slider.value = 0.0 _pendingValue = 0.0 slider._lastSentValue = 0.0 @@ -553,14 +575,14 @@ Window { if (slider.isResetting) { // Force backend to 0 on release (redundant but safe) - controller.set_edit_parameter(model.key, 0.0) + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, 0.0) } else { // Send final value immediately - var sendValue = isReversed ? -value : value - controller.set_edit_parameter(model.key, sendValue / maxVal) + var sendValue = sliderRow.isReversed ? -value : value + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, sendValue / sliderRow.maxVal) } - if (controller) controller.update_histogram() + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.update_histogram() } } @@ -635,25 +657,25 @@ Window { // Refined SpinBox SpinBox { id: valueInput - from: minVal - to: maxVal + from: sliderRow.minVal + to: sliderRow.maxVal stepSize: 1 editable: true Layout.preferredWidth: 80 Layout.alignment: Qt.AlignVCenter - value: isReversed ? -slider.value : slider.value + value: sliderRow.isReversed ? -slider.value : slider.value onValueModified: { var val = value - var sendValue = isReversed ? -val : val - controller.set_edit_parameter(model.key, sendValue / maxVal) + var sendValue = sliderRow.isReversed ? -val : val + if (imageEditorDialog.controllerRef) imageEditorDialog.controllerRef.set_edit_parameter(sliderRow.key, sendValue / sliderRow.maxVal) imageEditorDialog.updatePulse++ } contentItem: TextInput { z: 2 - text: valueInput.textFromValue(valueInput.value, valueInput.locale) + text: valueInput.displayText font.pixelSize: 12 font.family: valueInput.font.family color: imageEditorDialog.textColor diff --git a/faststack/qml/JumpToImageDialog.qml b/faststack/qml/JumpToImageDialog.qml index b5d5e3d..ffca9e6 100644 --- a/faststack/qml/JumpToImageDialog.qml +++ b/faststack/qml/JumpToImageDialog.qml @@ -14,6 +14,7 @@ Dialog { property int maxImageCount: 0 property color backgroundColor: "red" // Placeholder, will be set from Main.qml property color textColor: "white" // Placeholder, will be set from Main.qml + property var controllerRef: typeof controller !== "undefined" ? controller : null // Inherit Material theme from parent @@ -27,18 +28,18 @@ Dialog { imageNumberField.text = "" imageNumberField.forceActiveFocus() // Notify Python that a dialog is open - controller.dialog_opened() + if (jumpDialog.controllerRef) jumpDialog.controllerRef.dialog_opened() } onClosed: { // Notify Python that dialog is closed - controller.dialog_closed() + if (jumpDialog.controllerRef) jumpDialog.controllerRef.dialog_closed() } onAccepted: { var num = parseInt(imageNumberField.text) if (!isNaN(num) && num >= 1 && num <= maxImageCount) { - controller.jump_to_image(num - 1) // Convert 1-based to 0-based index + if (jumpDialog.controllerRef) jumpDialog.controllerRef.jump_to_image(num - 1) // Convert 1-based to 0-based index } } diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 0bc176f..dae1187 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1,6 +1,6 @@ +pragma ComponentBehavior: Bound import QtQuick import QtQuick.Window -import QtQuick.Dialogs import QtQuick.Controls 2.15 import QtQuick.Controls.Material 2.15 import QtQuick.Layouts 1.15 @@ -16,12 +16,14 @@ ApplicationWindow { flags: Qt.FramelessWindowHint | Qt.Window | Qt.WindowMinMaxButtonsHint title: "FastStack" + property var uiStateRef: null + property var controllerRef: null property bool allowCloseWithRecycleBins: false property bool fullScreenLoupe: false property var savedWindowGeometry: ({}) function enterFullScreenLoupe() { - if (!uiState || uiState.isGridViewActive) return + if (!root.uiStateRef || root.uiStateRef.isGridViewActive) return savedWindowGeometry = { x: root.x, @@ -63,13 +65,13 @@ ApplicationWindow { } onClosing: function(close) { - if (allowCloseWithRecycleBins) { + if (root.allowCloseWithRecycleBins) { close.accepted = true return } - if (uiState && uiState.hasRecycleBinItems) { + if (root.uiStateRef && root.uiStateRef.hasRecycleBinItems) { close.accepted = false - uiState.refreshRecycleBinStats() + root.uiStateRef.refreshRecycleBinStats() recycleBinCleanupDialog.open() } else { close.accepted = true @@ -77,10 +79,12 @@ ApplicationWindow { } Component.onCompleted: { + root.uiStateRef = uiState + root.controllerRef = controller // Initialization complete } - Material.theme: (uiState && uiState.theme === 0) ? Material.Dark : Material.Light + Material.theme: (root.uiStateRef && root.uiStateRef.theme === 0) ? Material.Dark : Material.Light Material.accent: "#4fb360" // Frameless windows on Windows report FullScreen instead of Maximized @@ -89,17 +93,19 @@ ApplicationWindow { property bool isMaximized: root.visibility === Window.Maximized || (root.visibility === Window.FullScreen && !root.fullScreenLoupe) - property bool isDarkTheme: uiState ? uiState.theme === 0 : true + property bool isDarkTheme: root.uiStateRef ? root.uiStateRef.theme === 0 : true property color currentBackgroundColor: isDarkTheme ? "#000000" : "#ffffff" property color currentTextColor: isDarkTheme ? "white" : "black" property color hoverColor: isDarkTheme ? Qt.lighter(currentBackgroundColor, 1.5) : Qt.darker(currentBackgroundColor, 1.1) + property color menuHoverColor: isDarkTheme ? "#555555" : "#e0e0e0" + property color menuSelectedColor: isDarkTheme ? "#505050" : "#d0ffd0" background: Rectangle { color: root.currentBackgroundColor } function toggleTheme() { - if (uiState) { - uiState.theme = (uiState.theme === 0 ? 1 : 0) + if (root.uiStateRef) { + root.uiStateRef.theme = (root.uiStateRef.theme === 0 ? 1 : 0) } } @@ -109,6 +115,14 @@ ApplicationWindow { exifDialog.open() } + function setGridPrefetch(item, enabled) { + var methodName = "set" + "PrefetchEnabled" + var setter = item ? item[methodName] : null + if (typeof setter === "function") { + setter.call(item, enabled) + } + } + // -------- CUSTOM TITLE BAR -------- property int titleBarHeight: 36 @@ -305,7 +319,7 @@ ApplicationWindow { TapHandler { onDoubleTapped: { - if (uiState && uiState.debugMode) + if (root.uiStateRef && root.uiStateRef.debugMode) console.log("[TitleBar] double-tap: visibility =", root.visibility, "isMaximized =", root.isMaximized, "fullScreenLoupe =", root.fullScreenLoupe) @@ -314,7 +328,7 @@ ApplicationWindow { } else { root.showMaximized() } - if (uiState && uiState.debugMode) + if (root.uiStateRef && root.uiStateRef.debugMode) console.log("[TitleBar] double-tap: after visibility =", root.visibility, "isMaximized =", root.isMaximized) } @@ -325,7 +339,7 @@ ApplicationWindow { target: null // we move the window, not this item onActiveChanged: { if (active) { - if (uiState && uiState.debugMode) + if (root.uiStateRef && root.uiStateRef.debugMode) console.log("[TitleBar] drag-start: starting system move") root.startSystemMove() } @@ -350,7 +364,7 @@ ApplicationWindow { text: { if (!loupe || fs <= 0 || zs <= 0) return "" - if (uiState && uiState.isGridViewActive) return "" + if (root.uiStateRef && root.uiStateRef.isGridViewActive) return "" var ratio = zs / fs if (Math.abs(ratio - 1.0) < 0.03) return "Zoom: Fit to window (" + Math.round(zs * 100) + "%)" return "Zoom: " + Math.round(zs * 100) + "%" @@ -550,63 +564,39 @@ ApplicationWindow { contentItem: Column { id: fileMenuColumn - ItemDelegate { + MenuActionItem { width: 200 - height: 36 text: "Open Folder..." + hoverFillColor: root.hoverColor onClicked: { - if (uiState) { - uiState.open_folder() + if (root.uiStateRef) { + root.uiStateRef.open_folder() } fileMenu.close() } - background: Rectangle { - color: parent.hovered ? hoverColor : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + defaultTextColor: root.currentTextColor } - ItemDelegate { + MenuActionItem { width: 200 - height: 36 text: "Settings..." + hoverFillColor: root.menuHoverColor onClicked: { settingsDialog.open() fileMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + defaultTextColor: root.currentTextColor } Rectangle { width: 200 height: 1 color: root.isDarkTheme ? "#666666" : "#cccccc" } - ItemDelegate { + MenuActionItem { width: 200 - height: 36 text: "Exit" + hoverFillColor: root.menuHoverColor onClicked: Qt.quit() - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + defaultTextColor: root.currentTextColor } } } @@ -628,23 +618,15 @@ ApplicationWindow { id: viewMenuColumn // Toggle theme - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Toggle Light/Dark Mode" + hoverFillColor: root.menuHoverColor onClicked: { root.toggleTheme() viewMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + defaultTextColor: root.currentTextColor } // Separator @@ -655,75 +637,45 @@ ApplicationWindow { } // Color: None (Original) - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Color: None (Original)" + hoverFillColor: root.menuHoverColor + selectedFillColor: root.menuSelectedColor + selected: root.uiStateRef && root.uiStateRef.colorMode === "none" + defaultTextColor: root.currentTextColor onClicked: { - if (controller) controller.set_color_mode("none") + if (root.controllerRef) root.controllerRef.set_color_mode("none") viewMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.colorMode === "none") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.colorMode === "none" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } // Color: Saturation Compensation - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Color: Saturation Compensation" + hoverFillColor: root.menuHoverColor + selectedFillColor: root.menuSelectedColor + selected: root.uiStateRef && root.uiStateRef.colorMode === "saturation" + defaultTextColor: root.currentTextColor onClicked: { - if (controller) controller.set_color_mode("saturation") + if (root.controllerRef) root.controllerRef.set_color_mode("saturation") viewMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.colorMode === "saturation") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.colorMode === "saturation" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } // Color: Full ICC Profile - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Color: Full ICC Profile" + hoverFillColor: root.menuHoverColor + selectedFillColor: root.menuSelectedColor + selected: root.uiStateRef && root.uiStateRef.colorMode === "icc" + defaultTextColor: root.currentTextColor onClicked: { - if (controller) controller.set_color_mode("icc") + if (root.controllerRef) root.controllerRef.set_color_mode("icc") viewMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.colorMode === "icc") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.colorMode === "icc" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } } } @@ -746,160 +698,89 @@ ApplicationWindow { id: actionsMenuColumn // Develop RAW (True Headroom) - ItemDelegate { + MenuActionItem { width: 220 - height: 36 - text: (uiState && uiState.hasWorkingTif) ? "Re-develop RAW" : "Develop RAW" - enabled: uiState ? uiState.hasRaw : false + text: (root.uiStateRef && root.uiStateRef.hasWorkingTif) ? "Re-develop RAW" : "Develop RAW" + enabled: root.uiStateRef ? root.uiStateRef.hasRaw : false + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + disabledTextColor: root.isDarkTheme ? "#666666" : "#999999" onClicked: { - if (uiState) uiState.developRaw() + if (root.uiStateRef) root.uiStateRef.developRaw() actionsMenu.close() } - background: Rectangle { - color: (parent.enabled && parent.hovered) ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: enabled ? root.currentTextColor : (root.isDarkTheme ? "#666666" : "#999999") - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } // Edit Image (from old Main.qml) - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Edit Image" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (uiState) { - uiState.isEditorOpen = !uiState.isEditorOpen - if (uiState.isEditorOpen && controller) { - controller.load_image_for_editing() + if (root.uiStateRef) { + root.uiStateRef.isEditorOpen = !root.uiStateRef.isEditorOpen + if (root.uiStateRef.isEditorOpen && root.controllerRef) { + root.controllerRef.load_image_for_editing() } } actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Crop Image" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (controller) { - controller.toggle_crop_mode() + if (root.controllerRef) { + root.controllerRef.toggle_crop_mode() } actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Run Stacks (raw)" - onClicked: { if (uiState) uiState.launch_helicon(true); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + onClicked: { if (root.uiStateRef) root.uiStateRef.launch_helicon(true); actionsMenu.close() } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Run Stacks (jpg)" - onClicked: { if (uiState) uiState.launch_helicon(false); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + onClicked: { if (root.uiStateRef) root.uiStateRef.launch_helicon(false); actionsMenu.close() } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Clear Stacks" - onClicked: { if (uiState) uiState.clear_all_stacks(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + onClicked: { if (root.uiStateRef) root.uiStateRef.clear_all_stacks(); actionsMenu.close() } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Show Stacks" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { showStacksDialog.open(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Preload All Images" - onClicked: { if (uiState) uiState.preloadAllImages(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + onClicked: { if (root.uiStateRef) root.uiStateRef.preloadAllImages(); actionsMenu.close() } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Filter Images..." + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { filterDialog.open(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } // Separator before Sort options @@ -915,7 +796,7 @@ ApplicationWindow { height: 36 hoverEnabled: true background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + color: sortPhotosLauncher.hovered ? root.menuHoverColor : "transparent" } contentItem: Item { Text { @@ -952,115 +833,67 @@ ApplicationWindow { } // Clear Filename Filter (from old Main.qml) - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Clear Filename Filter" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (controller) controller.clear_filter() + if (root.controllerRef) root.controllerRef.clear_filter() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Add Favorites to Batch" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (uiState) uiState.addFavoritesToBatch() + if (root.uiStateRef) root.uiStateRef.addFavoritesToBatch() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Add Uploaded to Batch" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (uiState) uiState.addUploadedToBatch() + if (root.uiStateRef) root.uiStateRef.addUploadedToBatch() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Jump to Last Uploaded" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (uiState) uiState.jumpToLastUploaded() + if (root.uiStateRef) root.uiStateRef.jumpToLastUploaded() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Auto-Level Batch" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (uiState) uiState.batchAutoLevels() + if (root.uiStateRef) root.uiStateRef.batchAutoLevels() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 220 - height: 36 text: "Stack Source RAWs" - enabled: uiState ? uiState.isStackedJpg : false + enabled: root.uiStateRef ? root.uiStateRef.isStackedJpg : false + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor + disabledTextColor: root.isDarkTheme ? "#666666" : "#999999" onClicked: { - if (uiState) uiState.stack_source_raws(); + if (root.uiStateRef) root.uiStateRef.stack_source_raws(); actionsMenu.close() } - background: Rectangle { - color: (parent.enabled && parent.hovered) ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: parent.enabled ? root.currentTextColor : (root.isDarkTheme ? "#666666" : "#999999") - opacity: parent.enabled ? 1.0 : 0.6 - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } // Separator before grid view toggle @@ -1071,23 +904,15 @@ ApplicationWindow { } // Toggle Grid/Loupe View - ItemDelegate { + MenuActionItem { width: 220 - height: 36 - text: uiState && uiState.isGridViewActive ? "Single Image View" : "Thumbnail View" + text: root.uiStateRef && root.uiStateRef.isGridViewActive ? "Single Image View" : "Thumbnail View" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { - if (uiState) uiState.toggleGridView(); + if (root.uiStateRef) root.uiStateRef.toggleGridView(); actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } } } @@ -1108,74 +933,44 @@ ApplicationWindow { contentItem: Column { id: sortSubMenuColumn - ItemDelegate { + MenuActionItem { width: 180 - height: 36 text: "Default" + hoverFillColor: root.menuHoverColor + selectedFillColor: root.menuSelectedColor + selected: root.uiStateRef && root.uiStateRef.sortMode === "default" + defaultTextColor: root.currentTextColor onClicked: { - if (controller) controller.set_sort_mode("default") + if (root.controllerRef) root.controllerRef.set_sort_mode("default") sortSubMenu.close() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.sortMode === "default") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.sortMode === "default" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 180 - height: 36 text: "By Filename" + hoverFillColor: root.menuHoverColor + selectedFillColor: root.menuSelectedColor + selected: root.uiStateRef && root.uiStateRef.sortMode === "filename" + defaultTextColor: root.currentTextColor onClicked: { - if (controller) controller.set_sort_mode("filename") + if (root.controllerRef) root.controllerRef.set_sort_mode("filename") sortSubMenu.close() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.sortMode === "filename") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.sortMode === "filename" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } - ItemDelegate { + MenuActionItem { width: 180 - height: 36 text: "By Date" + hoverFillColor: root.menuHoverColor + selectedFillColor: root.menuSelectedColor + selected: root.uiStateRef && root.uiStateRef.sortMode === "date" + defaultTextColor: root.currentTextColor onClicked: { - if (controller) controller.set_sort_mode("date") + if (root.controllerRef) root.controllerRef.set_sort_mode("date") sortSubMenu.close() actionsMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") - : ((uiState && uiState.sortMode === "date") - ? (root.isDarkTheme ? "#505050" : "#d0ffd0") - : "transparent") - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - font.bold: uiState && uiState.sortMode === "date" - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } } } @@ -1196,20 +991,12 @@ ApplicationWindow { contentItem: Column { id: helpMenuColumn - ItemDelegate { + MenuActionItem { width: 200 - height: 36 text: "Key Bindings" + hoverFillColor: root.menuHoverColor + defaultTextColor: root.currentTextColor onClicked: { aboutDialog.open(); helpMenu.close() } - background: Rectangle { - color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" - } - contentItem: Text { - text: parent.text - color: root.currentTextColor - verticalAlignment: Text.AlignVCenter - leftPadding: 10 - } } } } @@ -1220,7 +1007,7 @@ ApplicationWindow { Shortcut { sequence: "F11" context: Qt.ApplicationShortcut - enabled: uiState ? !uiState.isGridViewActive && !uiState.isDialogOpen : false + enabled: root.uiStateRef ? !root.uiStateRef.isGridViewActive && !root.uiStateRef.isDialogOpen : false onActivated: root.toggleFullScreenLoupe() } @@ -1234,16 +1021,16 @@ ApplicationWindow { Shortcut { sequence: "E" context: Qt.ApplicationShortcut - enabled: uiState ? !uiState.isDialogOpen : true + enabled: root.uiStateRef ? !root.uiStateRef.isDialogOpen : true onActivated: { - if (!uiState) return + if (!root.uiStateRef) return - if (uiState.isEditorOpen) { - uiState.isEditorOpen = false + if (root.uiStateRef.isEditorOpen) { + root.uiStateRef.isEditorOpen = false } else { - uiState.isEditorOpen = true - if (controller) { - controller.load_image_for_editing() + root.uiStateRef.isEditorOpen = true + if (root.controllerRef) { + root.controllerRef.load_image_for_editing() } } } @@ -1253,13 +1040,13 @@ ApplicationWindow { Shortcut { sequence: "K" context: Qt.ApplicationShortcut - enabled: uiState ? !uiState.isDialogOpen && !uiState.isCropping : false + enabled: root.uiStateRef ? !root.uiStateRef.isDialogOpen && !root.uiStateRef.isCropping : false onActivated: { - if (!uiState || !controller) return - if (uiState.isDarkening) { - controller.toggle_darken_mode() + if (!root.uiStateRef || !root.controllerRef) return + if (root.uiStateRef.isDarkening) { + root.controllerRef.toggle_darken_mode() } else { - controller.open_darken_tool() + root.controllerRef.open_darken_tool() } } } @@ -1268,45 +1055,41 @@ ApplicationWindow { Shortcut { sequence: "T" context: Qt.ApplicationShortcut - enabled: uiState ? !uiState.isDialogOpen : true + enabled: root.uiStateRef ? !root.uiStateRef.isDialogOpen : true onActivated: { - if (uiState) uiState.toggleGridView() + if (root.uiStateRef) root.uiStateRef.toggleGridView() } } // Handle View Switching and Prefetch Gating Connections { - target: uiState + target: root.uiStateRef function onIsGridViewActiveChanged() { - if (uiState.isGridViewActive && root.fullScreenLoupe) { + if (root.uiStateRef.isGridViewActive && root.fullScreenLoupe) { root.exitFullScreenLoupe() } var gridItem = gridViewLoader.item if (!gridItem) return - if (uiState.isGridViewActive) { + if (root.uiStateRef.isGridViewActive) { // Switching TO grid: // 1. Immediately disable prefetch to block transient top-of-list requests // that happen before the view layout/scroll is restored. - if (typeof gridItem.setPrefetchEnabled === "function") { - gridItem.setPrefetchEnabled(false) - } + root.setGridPrefetch(gridItem, false) // 2. Re-enable on next event loop tick. // This allows the GridView to restore its currentIndex/contentY position. Qt.callLater(function() { var it = gridViewLoader.item - if (uiState.isGridViewActive && it && typeof it.setPrefetchEnabled === "function") { - it.setPrefetchEnabled(true) + if (root.uiStateRef.isGridViewActive && it) { + root.setGridPrefetch(it, true) } }) } else { // Switching AWAY from grid: // Disable immediately to stop background work. - if (typeof gridItem.setPrefetchEnabled === "function") { - gridItem.setPrefetchEnabled(false) - } + root.setGridPrefetch(gridItem, false) } } } @@ -1319,7 +1102,7 @@ ApplicationWindow { anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom - currentIndex: uiState && uiState.isGridViewActive ? 1 : 0 + currentIndex: root.uiStateRef && root.uiStateRef.isGridViewActive ? 1 : 0 // Index 0: Loupe View (single image) Item { @@ -1331,19 +1114,22 @@ ApplicationWindow { id: mainViewLoader anchors.fill: parent source: "Components.qml" - focus: !uiState || !uiState.isGridViewActive - onLoaded: item.footerHeight = Qt.binding(function() { return root.effectiveFooterHeight }) + focus: !root.uiStateRef || !root.uiStateRef.isGridViewActive + onLoaded: { + item.footerHeight = Qt.binding(function() { return root.effectiveFooterHeight }) + item.isDarkTheme = Qt.binding(function() { return root.isDarkTheme }) + } // Key bindings implemented in old Main.qml Keys.onPressed: function(event) { - if (!uiState || !controller) { + if (!root.uiStateRef || !root.controllerRef) { return } // Global Key for saving edited image (Ctrl+S) when editor is open if (event.key === Qt.Key_S && (event.modifiers & Qt.ControlModifier)) { - if (uiState.isEditorOpen) { - controller.save_edited_image() + if (root.uiStateRef.isEditorOpen) { + root.controllerRef.save_edited_image() event.accepted = true } } @@ -1362,17 +1148,17 @@ ApplicationWindow { anchors.fill: parent source: "ThumbnailGridView.qml" active: true // Keep loaded to preserve state during view toggle - visible: uiState && uiState.isGridViewActive - focus: uiState && uiState.isGridViewActive + visible: root.uiStateRef && root.uiStateRef.isGridViewActive + focus: root.uiStateRef && root.uiStateRef.isGridViewActive onLoaded: { // Enable prefetch on startup if grid is active (single owner) var loadedItem = item - if (uiState && uiState.isGridViewActive && loadedItem && typeof loadedItem.setPrefetchEnabled === "function") { + if (root.uiStateRef && root.uiStateRef.isGridViewActive && loadedItem) { // Delay to match the toggle behavior (allow layout to settle) Qt.callLater(function() { - if (gridViewLoader.item === loadedItem && uiState.isGridViewActive) { - loadedItem.setPrefetchEnabled(true) + if (gridViewLoader.item === loadedItem && root.uiStateRef.isGridViewActive) { + root.setGridPrefetch(loadedItem, true) } }) } @@ -1413,31 +1199,31 @@ ApplicationWindow { Label { Layout.leftMargin: 10 - text: uiState ? `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` : "Image: - / -" + text: root.uiStateRef ? `Image: ${root.uiStateRef.currentIndex + 1} / ${root.uiStateRef.imageCount}` : "Image: - / -" color: root.currentTextColor } Label { - text: (uiState && uiState.imageCount > 0) - ? (uiState.currentFilename || "N/A") + text: (root.uiStateRef && root.uiStateRef.imageCount > 0) + ? (root.uiStateRef.currentFilename || "N/A") : "N/A" color: root.currentTextColor } Label { - visible: (uiState && uiState.imageCount > 0 && uiState.exifBrief && uiState.exifBrief.length > 0) - text: uiState ? (uiState.exifBrief || "") : "" + visible: (root.uiStateRef && root.uiStateRef.imageCount > 0 && root.uiStateRef.exifBrief && root.uiStateRef.exifBrief.length > 0) + text: root.uiStateRef ? (root.uiStateRef.exifBrief || "") : "" color: root.currentTextColor } Label { id: directoryPathLabel - visible: uiState && uiState.currentDirectory !== "" - text: uiState ? uiState.currentDirectory : "" + visible: root.uiStateRef && root.uiStateRef.currentDirectory !== "" + text: root.uiStateRef ? root.uiStateRef.currentDirectory : "" color: root.isDarkTheme ? "#888888" : "#777777" font.pixelSize: 11 elide: Text.ElideMiddle Layout.maximumWidth: 300 ToolTip.visible: directoryPathMouse.containsMouse && text !== "" - ToolTip.text: uiState ? uiState.currentDirectory : "" + ToolTip.text: root.uiStateRef ? root.uiStateRef.currentDirectory : "" ToolTip.delay: 500 MouseArea { @@ -1449,82 +1235,82 @@ ApplicationWindow { } Item { Layout.fillWidth: true } Label { - text: uiState ? ` Stacked: ${uiState.stackedDate}` : "" + text: root.uiStateRef ? ` Stacked: ${root.uiStateRef.stackedDate}` : "" color: "lightgreen" - visible: uiState ? (uiState.imageCount > 0 && uiState.isStacked) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.isStacked) : false } Label { - text: uiState ? ` Uploaded on ${uiState.uploadedDate}` : "" + text: root.uiStateRef ? ` Uploaded on ${root.uiStateRef.uploadedDate}` : "" color: "lightgreen" - visible: uiState ? (uiState.imageCount > 0 && uiState.isUploaded) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.isUploaded) : false } Label { - text: uiState ? (uiState.todoDate ? ` Todo since ${uiState.todoDate}` : " Todo") : "" + text: root.uiStateRef ? (root.uiStateRef.todoDate ? ` Todo since ${root.uiStateRef.todoDate}` : " Todo") : "" color: "#64B5F6" - visible: uiState ? (uiState.imageCount > 0 && uiState.isTodo) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.isTodo) : false } Label { - text: uiState ? ` Edited on ${uiState.editedDate}` : "" + text: root.uiStateRef ? ` Edited on ${root.uiStateRef.editedDate}` : "" color: "lightgreen" - visible: uiState ? (uiState.imageCount > 0 && uiState.isEdited) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.isEdited) : false } Label { - text: uiState ? ` Restacked on ${uiState.restackedDate}` : "" + text: root.uiStateRef ? ` Restacked on ${root.uiStateRef.restackedDate}` : "" color: "cyan" - visible: uiState ? (uiState.imageCount > 0 && uiState.isRestacked) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.isRestacked) : false } Label { text: " Favorite" color: "gold" - visible: uiState ? (uiState.imageCount > 0 && uiState.isFavorite) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.isFavorite) : false } Label { - text: uiState ? ` Filter: "${uiState.filterString}"` : "" + text: root.uiStateRef ? ` Filter: "${root.uiStateRef.filterString}"` : "" color: "yellow" font.bold: true - visible: uiState ? (uiState.filterString !== "") : false + visible: root.uiStateRef ? (root.uiStateRef.filterString !== "") : false } Rectangle { - visible: uiState ? uiState.isPreloading : false + visible: root.uiStateRef ? root.uiStateRef.isPreloading : false Layout.preferredWidth: 200 - height: 10 // give it some height + Layout.preferredHeight: 10 color: "gray" border.color: "red" border.width: 1 Rectangle { color: "lightblue" - width: parent.width * (uiState ? uiState.preloadProgress / 100 : 0) + width: parent.width * (root.uiStateRef ? root.uiStateRef.preloadProgress / 100 : 0) height: parent.height } } Rectangle { - color: (uiState && uiState.imageCount > 0 && uiState.stackInfoText) ? "orange" : "transparent" + color: (root.uiStateRef && root.uiStateRef.imageCount > 0 && root.uiStateRef.stackInfoText) ? "orange" : "transparent" radius: 3 implicitWidth: stackInfoLabel.implicitWidth + 10 implicitHeight: stackInfoLabel.implicitHeight + 5 - visible: uiState ? (uiState.imageCount > 0 && uiState.stackInfoText) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.stackInfoText) : false Label { id: stackInfoLabel anchors.centerIn: parent - text: uiState ? `Stack: ${uiState.stackInfoText}` : "" + text: root.uiStateRef ? `Stack: ${root.uiStateRef.stackInfoText}` : "" color: "black" font.bold: true font.pixelSize: 16 } } Rectangle { - color: (uiState && uiState.imageCount > 0 && uiState.batchInfoText) ? "#4fb360" : "transparent" + color: (root.uiStateRef && root.uiStateRef.imageCount > 0 && root.uiStateRef.batchInfoText) ? "#4fb360" : "transparent" radius: 3 implicitWidth: batchInfoLabel.implicitWidth + 10 implicitHeight: batchInfoLabel.implicitHeight + 5 - visible: uiState ? (uiState.imageCount > 0 && uiState.batchInfoText) : false + visible: root.uiStateRef ? (root.uiStateRef.imageCount > 0 && root.uiStateRef.batchInfoText) : false Label { id: batchInfoLabel anchors.centerIn: parent - text: uiState ? `Batch: ${uiState.batchInfoText}` : "" + text: root.uiStateRef ? `Batch: ${root.uiStateRef.batchInfoText}` : "" color: "white" font.bold: true font.pixelSize: 16 @@ -1533,12 +1319,15 @@ ApplicationWindow { // Variant badges (loupe view only, when multiple variants exist) Row { spacing: 4 - visible: uiState && !uiState.isGridViewActive && uiState.variantBadges.length > 1 + visible: root.uiStateRef && !root.uiStateRef.isGridViewActive && root.uiStateRef.variantBadges.length > 1 Repeater { - model: uiState ? uiState.variantBadges : [] + model: root.uiStateRef ? root.uiStateRef.variantBadges : [] delegate: Rectangle { + id: variantBadge + required property var modelData + width: badgeLabel.implicitWidth + 12 height: 22 radius: 3 @@ -1549,24 +1338,24 @@ ApplicationWindow { Text { id: badgeLabel anchors.centerIn: parent - text: modelData.label + text: variantBadge.modelData.label font.pixelSize: 11 font.bold: true - color: modelData.active ? "black" : "white" + color: variantBadge.modelData.active ? "black" : "white" } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { - if (uiState) uiState.setVariantOverride(modelData.path) + if (root.uiStateRef) root.uiStateRef.setVariantOverride(variantBadge.modelData.path) } } } } Label { - text: uiState ? uiState.variantSaveHint : "" + text: root.uiStateRef ? root.uiStateRef.variantSaveHint : "" color: root.isDarkTheme ? "#aaa" : "#666" font.pixelSize: 11 font.italic: true @@ -1581,17 +1370,17 @@ ApplicationWindow { } Label { - text: uiState ? uiState.cacheStats : "" + text: root.uiStateRef ? root.uiStateRef.cacheStats : "" color: "#00FFFF" // Cyan font.family: "Monospace" - visible: uiState ? uiState.debugCache : false + visible: root.uiStateRef ? root.uiStateRef.debugCache : false Layout.rightMargin: 10 } // Saturation slider (only visible in saturation mode) Row { - visible: uiState && uiState.colorMode === "saturation" + visible: root.uiStateRef && root.uiStateRef.colorMode === "saturation" spacing: 5 Layout.rightMargin: 10 @@ -1605,12 +1394,12 @@ ApplicationWindow { id: saturationSlider from: 0.0 to: 1.0 - value: uiState ? uiState.saturationFactor : 1.0 + value: root.uiStateRef ? root.uiStateRef.saturationFactor : 1.0 stepSize: 0.01 width: 150 onMoved: { - if (controller) controller.set_saturation_factor(value) + if (root.controllerRef) root.controllerRef.set_saturation_factor(value) } } @@ -1624,23 +1413,23 @@ ApplicationWindow { Label { id: statusMessageLabel - text: uiState ? uiState.statusMessage : "" - color: (uiState && uiState.isSaving) ? "#4CAF50" : root.currentTextColor - font.bold: (uiState && uiState.isSaving) ? true : false - font.pixelSize: (uiState && uiState.isSaving) ? 14 : 12 - visible: uiState ? (uiState.statusMessage !== "") : false + text: root.uiStateRef ? root.uiStateRef.statusMessage : "" + color: (root.uiStateRef && root.uiStateRef.isSaving) ? "#4CAF50" : root.currentTextColor + font.bold: (root.uiStateRef && root.uiStateRef.isSaving) ? true : false + font.pixelSize: (root.uiStateRef && root.uiStateRef.isSaving) ? 14 : 12 + visible: root.uiStateRef ? (root.uiStateRef.statusMessage !== "") : false Layout.rightMargin: 10 } // Grid view controls (visible when in grid view) - right side Row { - visible: uiState && uiState.isGridViewActive + visible: root.uiStateRef && root.uiStateRef.isGridViewActive spacing: 10 Layout.rightMargin: 15 // Selection info (uses efficient count property, not full list) Label { - property int selCount: uiState ? uiState.gridSelectedCount : 0 + property int selCount: root.uiStateRef ? root.uiStateRef.gridSelectedCount : 0 text: selCount > 0 ? selCount + " selected" : "" color: "#4CAF50" font.bold: true @@ -1650,7 +1439,7 @@ ApplicationWindow { // Clear selection button Rectangle { - visible: uiState ? uiState.gridSelectedCount > 0 : false + visible: root.uiStateRef ? root.uiStateRef.gridSelectedCount > 0 : false width: clearLabel.implicitWidth + 16 height: 26 radius: 4 @@ -1670,13 +1459,13 @@ ApplicationWindow { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { if (uiState) uiState.gridClearSelection() } + onClicked: { if (root.uiStateRef) root.uiStateRef.gridClearSelection() } } } // Back button (only shown when there's history) Rectangle { - visible: uiState && uiState.gridCanGoBack + visible: root.uiStateRef && root.uiStateRef.gridCanGoBack width: backLabel.implicitWidth + 16 height: 26 radius: 4 @@ -1696,7 +1485,7 @@ ApplicationWindow { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { if (uiState) uiState.gridGoBack() } + onClicked: { if (root.uiStateRef) root.uiStateRef.gridGoBack() } } } @@ -1721,7 +1510,7 @@ ApplicationWindow { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { if (uiState) uiState.gridRefresh() } + onClicked: { if (root.uiStateRef) root.uiStateRef.gridRefresh() } } } @@ -1746,7 +1535,7 @@ ApplicationWindow { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: { if (uiState) uiState.toggleGridView() } + onClicked: { if (root.uiStateRef) root.uiStateRef.toggleGridView() } } } } @@ -1871,7 +1660,7 @@ ApplicationWindow { } contentItem: Text { - text: (uiState && uiState.stackSummary) ? uiState.stackSummary : "No stacks defined." + text: (root.uiStateRef && root.uiStateRef.stackSummary) ? root.uiStateRef.stackSummary : "No stacks defined." padding: 10 wrapMode: Text.WordWrap color: root.currentTextColor @@ -1887,7 +1676,7 @@ ApplicationWindow { backgroundColor: root.currentBackgroundColor textColor: root.currentTextColor onAccepted: { - if (uiState) uiState.applyFilter(filterString, filterFlags) + if (root.uiStateRef) root.uiStateRef.applyFilter(filterString, filterFlags) } } @@ -1895,7 +1684,7 @@ ApplicationWindow { id: jumpToImageDialog backgroundColor: root.currentBackgroundColor textColor: root.currentTextColor - maxImageCount: uiState ? uiState.imageCount : 0 + maxImageCount: root.uiStateRef ? root.uiStateRef.imageCount : 0 } DeleteBatchDialog { @@ -1957,7 +1746,7 @@ ApplicationWindow { anchors.bottom: parent.bottom anchors.margins: 20 z: 9999 // Ensure it is on top of everything, including footer - visible: uiState ? (uiState.debugCache && uiState.isDecoding) : false + visible: root.uiStateRef ? (root.uiStateRef.debugCache && root.uiStateRef.isDecoding) : false Text { anchors.centerIn: parent @@ -1980,8 +1769,8 @@ ApplicationWindow { property var binInfo: [] function refreshBinInfo() { - if (uiState) { - binInfo = uiState.getPerBinRestoreInfo() + if (root.uiStateRef) { + binInfo = root.uiStateRef.getPerBinRestoreInfo() } } @@ -2027,7 +1816,7 @@ ApplicationWindow { // Summary line Label { width: dialogContent.width - 40 - text: uiState ? uiState.recycleBinStatsText : "Loading..." + text: root.uiStateRef ? root.uiStateRef.recycleBinStatsText : "Loading..." color: root.isDarkTheme ? "#efefef" : "#333333" wrapMode: Text.WordWrap font.pixelSize: 15 @@ -2040,6 +1829,9 @@ ApplicationWindow { model: recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "restorable" }) delegate: Rectangle { + id: restorableBin + required property var modelData + width: dialogContent.width - 40 height: binRowLayout.implicitHeight + 20 radius: 8 @@ -2060,7 +1852,7 @@ ApplicationWindow { spacing: 2 Label { - text: modelData.label + text: restorableBin.modelData.label color: root.isDarkTheme ? "#efefef" : "#333333" font.pixelSize: 14 font.bold: true @@ -2068,7 +1860,7 @@ ApplicationWindow { width: parent.width } Label { - text: modelData.dest_dir + text: restorableBin.modelData.dest_dir color: root.isDarkTheme ? "#888888" : "#999999" font.pixelSize: 11 elide: Text.ElideMiddle @@ -2077,13 +1869,13 @@ ApplicationWindow { Label { text: { var parts = [] - if (modelData.jpg_count > 0) parts.push(modelData.jpg_count + " JPG") - if (modelData.raw_count > 0) parts.push(modelData.raw_count + " RAW") - if (modelData.other_count > 0) parts.push(modelData.other_count + " other") + if (restorableBin.modelData.jpg_count > 0) parts.push(restorableBin.modelData.jpg_count + " JPG") + if (restorableBin.modelData.raw_count > 0) parts.push(restorableBin.modelData.raw_count + " RAW") + if (restorableBin.modelData.other_count > 0) parts.push(restorableBin.modelData.other_count + " other") var s = parts.join(", ") - if (modelData.legacy_count > 0) - s += " + " + modelData.legacy_count + " legacy" - return s + " \u2014 " + modelData.total_restorable + " restorable" + if (restorableBin.modelData.legacy_count > 0) + s += " + " + restorableBin.modelData.legacy_count + " legacy" + return s + " \u2014 " + restorableBin.modelData.total_restorable + " restorable" } color: root.isDarkTheme ? "#aaaaaa" : "#666666" font.pixelSize: 13 @@ -2092,8 +1884,8 @@ ApplicationWindow { // Per-bin Restore button Rectangle { - width: restoreBinBtnText.implicitWidth + 30 - height: 34 + Layout.preferredWidth: restoreBinBtnText.implicitWidth + 30 + Layout.preferredHeight: 34 radius: 17 color: "#4fb360" Layout.alignment: Qt.AlignVCenter @@ -2111,8 +1903,8 @@ ApplicationWindow { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - if (uiState) { - uiState.restoreSingleBin(modelData.bin_path) + if (root.uiStateRef) { + root.uiStateRef.restoreSingleBin(restorableBin.modelData.bin_path) recycleBinCleanupDialog.refreshBinInfo() // Auto-close if nothing left if (recycleBinCleanupDialog.binInfo.length === 0) { @@ -2155,8 +1947,11 @@ ApplicationWindow { model: recycleBinCleanupDialog.binInfo.filter(function(b) { return b.status === "unavailable" }) delegate: Label { + id: unavailableBin + required property var modelData + width: dialogContent.width - 40 - text: modelData.dest_dir + " \u2014 " + modelData.total_files + " file" + (modelData.total_files !== 1 ? "s" : "") + text: unavailableBin.modelData.dest_dir + " \u2014 " + unavailableBin.modelData.total_files + " file" + (unavailableBin.modelData.total_files !== 1 ? "s" : "") color: root.isDarkTheme ? "#aaaaaa" : "#666666" font.pixelSize: 13 elide: Text.ElideMiddle @@ -2228,7 +2023,7 @@ ApplicationWindow { TextArea { id: detailsText width: detailsScrollView.availableWidth - text: uiState ? uiState.recycleBinDetailedText : "" + text: root.uiStateRef ? root.uiStateRef.recycleBinDetailedText : "" color: root.isDarkTheme ? "#efefef" : "#333333" font.family: "Consolas, 'Courier New', monospace" font.pixelSize: 13 @@ -2295,7 +2090,7 @@ ApplicationWindow { anchors.fill: parent hoverEnabled: true onClicked: { - allowCloseWithRecycleBins = true + root.allowCloseWithRecycleBins = true recycleBinCleanupDialog.close() Qt.quit() } @@ -2324,8 +2119,8 @@ ApplicationWindow { anchors.fill: parent hoverEnabled: true onClicked: { - if (uiState) uiState.cleanupRecycleBins() - allowCloseWithRecycleBins = true + if (root.uiStateRef) root.uiStateRef.cleanupRecycleBins() + root.allowCloseWithRecycleBins = true recycleBinCleanupDialog.close() Qt.quit() } diff --git a/faststack/qml/MenuActionItem.qml b/faststack/qml/MenuActionItem.qml new file mode 100644 index 0000000..c36488d --- /dev/null +++ b/faststack/qml/MenuActionItem.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Controls 2.15 + +ItemDelegate { + id: menuActionItem + + property color hoverFillColor: "transparent" + property color selectedFillColor: "transparent" + property color defaultTextColor: "white" + property color disabledTextColor: "#888888" + property bool selected: false + property bool boldWhenSelected: true + property bool useEnabledHover: true + property real disabledTextOpacity: 0.6 + property int textLeftPadding: 10 + + height: 36 + hoverEnabled: true + + background: Rectangle { + color: ((menuActionItem.enabled || !menuActionItem.useEnabledHover) && menuActionItem.hovered) + ? menuActionItem.hoverFillColor + : (menuActionItem.selected ? menuActionItem.selectedFillColor : "transparent") + } + + contentItem: Text { + text: menuActionItem.text + font.bold: menuActionItem.boldWhenSelected && menuActionItem.selected + color: menuActionItem.enabled ? menuActionItem.defaultTextColor : menuActionItem.disabledTextColor + opacity: menuActionItem.enabled ? 1.0 : menuActionItem.disabledTextOpacity + verticalAlignment: Text.AlignVCenter + leftPadding: menuActionItem.textLeftPadding + elide: Text.ElideRight + } +} diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml index 2f78a1a..9f67ba5 100644 --- a/faststack/qml/SettingsDialog.qml +++ b/faststack/qml/SettingsDialog.qml @@ -1,3 +1,4 @@ +pragma ComponentBehavior: Bound import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Controls.Material 2.15 @@ -41,6 +42,8 @@ Window { property int awbLumaUpperBound: 220 property int awbRgbLowerBound: 5 property int awbRgbUpperBound: 250 + property var uiStateRef: null + property var controllerRef: null // Live cache usage value (updated by timer) property real cacheUsage: 0.0 @@ -59,81 +62,145 @@ Window { Material.accent: accentColor color: backgroundColor + Component.onCompleted: { + settingsDialog.uiStateRef = uiState + settingsDialog.controllerRef = controller + } + + function loaderItem(loader) { + return loader ? loader.item : null + } + + function loaderProperty(loader, propertyName, fallbackValue) { + var control = settingsDialog.loaderItem(loader) + var value = control ? control[propertyName] : undefined + return value === undefined ? fallbackValue : value + } + + function setLoaderProperty(loader, propertyName, value) { + var control = settingsDialog.loaderItem(loader) + if (control) { + control[propertyName] = value + } + } + + function setLoaderBinding(loader, propertyName, bindingFn) { + var control = settingsDialog.loaderItem(loader) + if (control) { + control[propertyName] = Qt.binding(bindingFn) + } + } + + function connectLoaderSignal(loader, signalName, callback) { + var control = settingsDialog.loaderItem(loader) + var signal = control ? control[signalName] : null + if (signal && typeof signal.connect === "function") { + signal.connect(callback) + } + } + + function openFileDialog() { + return settingsDialog.uiStateRef ? settingsDialog.uiStateRef.open_file_dialog() : "" + } + + function openDirectoryDialog() { + return settingsDialog.uiStateRef ? settingsDialog.uiStateRef.open_directory_dialog() : "" + } + + function pathExists(path) { + return settingsDialog.uiStateRef ? settingsDialog.uiStateRef.check_path_exists(path) : false + } + + function refreshTextFields() { + settingsDialog.setLoaderProperty(heliconField, "text", settingsDialog.heliconPath) + settingsDialog.setLoaderProperty(photoshopField, "text", settingsDialog.photoshopPath) + settingsDialog.setLoaderProperty(rawtherapeeField, "text", settingsDialog.rawtherapeePath) + settingsDialog.setLoaderProperty(defaultDirField, "text", settingsDialog.defaultDirectory) + settingsDialog.setLoaderProperty(cacheSizeField, "text", settingsDialog.cacheSize.toFixed(1)) + settingsDialog.setLoaderProperty(prefetchRadiusLoader, "value", settingsDialog.prefetchRadius) + } + + onPrefetchRadiusChanged: settingsDialog.setLoaderProperty(prefetchRadiusLoader, "value", settingsDialog.prefetchRadius) + // Helper to open the dialog function open() { // Reload all properties from uiState to ensure Cancel discards edits - if (uiState) { - heliconPath = uiState.get_helicon_path() - photoshopPath = uiState.get_photoshop_path() - rawtherapeePath = uiState.get_rawtherapee_path() - cacheSize = uiState.get_cache_size() - prefetchRadius = uiState.get_prefetch_radius() - theme = uiState.theme - defaultDirectory = uiState.get_default_directory() - optimizeFor = uiState.get_optimize_for() - autoLevelClippingThreshold = uiState.autoLevelClippingThreshold - autoLevelStrength = uiState.autoLevelStrength - autoLevelStrengthAuto = uiState.autoLevelStrengthAuto - awbMode = uiState.awbMode - awbStrength = uiState.awbStrength - awbWarmBias = uiState.awbWarmBias - awbTintBias = uiState.awbTintBias - awbLumaLowerBound = uiState.awbLumaLowerBound - awbLumaUpperBound = uiState.awbLumaUpperBound - awbRgbLowerBound = uiState.awbRgbLowerBound - awbRgbUpperBound = uiState.awbRgbUpperBound + if (settingsDialog.uiStateRef) { + settingsDialog.heliconPath = settingsDialog.uiStateRef.get_helicon_path() + settingsDialog.photoshopPath = settingsDialog.uiStateRef.get_photoshop_path() + settingsDialog.rawtherapeePath = settingsDialog.uiStateRef.get_rawtherapee_path() + settingsDialog.cacheSize = settingsDialog.uiStateRef.get_cache_size() + settingsDialog.prefetchRadius = settingsDialog.uiStateRef.get_prefetch_radius() + settingsDialog.theme = settingsDialog.uiStateRef.theme + settingsDialog.defaultDirectory = settingsDialog.uiStateRef.get_default_directory() + settingsDialog.optimizeFor = settingsDialog.uiStateRef.get_optimize_for() + settingsDialog.autoLevelClippingThreshold = settingsDialog.uiStateRef.autoLevelClippingThreshold + settingsDialog.autoLevelStrength = settingsDialog.uiStateRef.autoLevelStrength + settingsDialog.autoLevelStrengthAuto = settingsDialog.uiStateRef.autoLevelStrengthAuto + settingsDialog.awbMode = settingsDialog.uiStateRef.awbMode + settingsDialog.awbStrength = settingsDialog.uiStateRef.awbStrength + settingsDialog.awbWarmBias = settingsDialog.uiStateRef.awbWarmBias + settingsDialog.awbTintBias = settingsDialog.uiStateRef.awbTintBias + settingsDialog.awbLumaLowerBound = settingsDialog.uiStateRef.awbLumaLowerBound + settingsDialog.awbLumaUpperBound = settingsDialog.uiStateRef.awbLumaUpperBound + settingsDialog.awbRgbLowerBound = settingsDialog.uiStateRef.awbRgbLowerBound + settingsDialog.awbRgbUpperBound = settingsDialog.uiStateRef.awbRgbUpperBound } - visible = true - raise() - requestActivate() + settingsDialog.visible = true + settingsDialog.raise() + settingsDialog.requestActivate() } Shortcut { sequence: "Escape" context: Qt.WindowShortcut - onActivated: visible = false + onActivated: settingsDialog.visible = false } onVisibleChanged: { - cacheUsageTimer.running = visible - if (visible) { - controller.dialog_opened() - // Reset all text fields from properties - if (heliconField.item) heliconField.item.text = settingsDialog.heliconPath - if (photoshopField.item) photoshopField.item.text = settingsDialog.photoshopPath - if (rawtherapeeField.item) rawtherapeeField.item.text = settingsDialog.rawtherapeePath - if (defaultDirField.item) defaultDirField.item.text = settingsDialog.defaultDirectory - if (cacheSizeField.item) cacheSizeField.item.text = settingsDialog.cacheSize.toFixed(1) - // Note: ComboBoxes and SpinBoxes update automatically via bindings/connections + cacheUsageTimer.running = settingsDialog.visible + if (settingsDialog.visible) { + if (settingsDialog.controllerRef) { + settingsDialog.controllerRef.dialog_opened() + } + settingsDialog.refreshTextFields() } else { - controller.dialog_closed() + if (settingsDialog.controllerRef) { + settingsDialog.controllerRef.dialog_closed() + } } } function saveSettings() { - uiState.set_helicon_path(heliconPath) - uiState.set_photoshop_path(photoshopPath) - uiState.set_rawtherapee_path(rawtherapeePath) - uiState.set_cache_size(cacheSize) - uiState.set_prefetch_radius(prefetchRadius) - uiState.set_theme(theme) - uiState.set_default_directory(defaultDirectory) - uiState.set_optimize_for(optimizeFor) - uiState.autoLevelClippingThreshold = autoLevelClippingThreshold - uiState.autoLevelStrength = autoLevelStrength - uiState.autoLevelStrengthAuto = autoLevelStrengthAuto - - uiState.awbMode = awbMode - uiState.awbStrength = awbStrength - uiState.awbWarmBias = awbWarmBias - uiState.awbTintBias = awbTintBias - - uiState.awbLumaLowerBound = awbLumaLowerBound - uiState.awbLumaUpperBound = awbLumaUpperBound - uiState.awbRgbLowerBound = awbRgbLowerBound - uiState.awbRgbUpperBound = awbRgbUpperBound + var state = settingsDialog.uiStateRef + if (!state) { + settingsDialog.visible = false + return + } - visible = false + state.set_helicon_path(settingsDialog.heliconPath) + state.set_photoshop_path(settingsDialog.photoshopPath) + state.set_rawtherapee_path(settingsDialog.rawtherapeePath) + state.set_cache_size(settingsDialog.cacheSize) + state.set_prefetch_radius(settingsDialog.prefetchRadius) + state.set_theme(settingsDialog.theme) + state.set_default_directory(settingsDialog.defaultDirectory) + state.set_optimize_for(settingsDialog.optimizeFor) + state.autoLevelClippingThreshold = settingsDialog.autoLevelClippingThreshold + state.autoLevelStrength = settingsDialog.autoLevelStrength + state.autoLevelStrengthAuto = settingsDialog.autoLevelStrengthAuto + + state.awbMode = settingsDialog.awbMode + state.awbStrength = settingsDialog.awbStrength + state.awbWarmBias = settingsDialog.awbWarmBias + state.awbTintBias = settingsDialog.awbTintBias + + state.awbLumaLowerBound = settingsDialog.awbLumaLowerBound + state.awbLumaUpperBound = settingsDialog.awbLumaUpperBound + state.awbRgbLowerBound = settingsDialog.awbRgbLowerBound + state.awbRgbUpperBound = settingsDialog.awbRgbUpperBound + + settingsDialog.visible = false } // Component for Section Separator @@ -231,7 +298,7 @@ Window { contentItem: TextInput { z: 2 - text: control.textFromValue(control.value, control.locale) + text: control.displayText font.pixelSize: 13 color: settingsDialog.textColor selectionColor: settingsDialog.accentColor @@ -244,7 +311,10 @@ Window { // Update control.value when user finishes typing onEditingFinished: { - control.value = control.valueFromText(text, control.locale) + var parsedValue = parseInt(text, 10) + if (!isNaN(parsedValue)) { + control.value = parsedValue + } } } @@ -299,6 +369,7 @@ Window { Component { id: tabButton Rectangle { + id: tabButtonRoot property string text property int index @@ -309,15 +380,15 @@ Window { anchors.bottom: parent.bottom width: parent.width height: 2 - color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "transparent" + color: settingsDialog.currentTab === tabButtonRoot.index ? settingsDialog.accentColor : "transparent" Behavior on color { ColorAnimation { duration: 200 } } } Text { anchors.centerIn: parent - text: parent.text - color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "#80ffffff" - font.bold: settingsDialog.currentTab === index + text: tabButtonRoot.text + color: settingsDialog.currentTab === tabButtonRoot.index ? settingsDialog.accentColor : "#80ffffff" + font.bold: settingsDialog.currentTab === tabButtonRoot.index font.pixelSize: 14 } @@ -325,7 +396,7 @@ Window { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: settingsDialog.currentTab = index + onClicked: settingsDialog.currentTab = tabButtonRoot.index } } } @@ -406,28 +477,30 @@ Window { sourceComponent: styledTextField Layout.fillWidth: true onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.heliconPath - item.textEdited.connect(function() { settingsDialog.heliconPath = item.text }) + settingsDialog.setLoaderProperty(heliconField, "text", settingsDialog.heliconPath) + settingsDialog.connectLoaderSignal(heliconField, "textEdited", function() { + settingsDialog.heliconPath = settingsDialog.loaderProperty(heliconField, "text", settingsDialog.heliconPath) + }) } } Button { + id: heliconBrowseButton text: "Browse" flat: true onClicked: { - var path = uiState.open_file_dialog() + var path = settingsDialog.openFileDialog() if (path) { settingsDialog.heliconPath = path - if (heliconField.item) heliconField.item.text = path + settingsDialog.setLoaderProperty(heliconField, "text", path) } } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: heliconBrowseButton.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: heliconBrowseButton.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } } Label { text: "✔" color: "#4ade80" - visible: uiState && uiState.check_path_exists(settingsDialog.heliconPath) + visible: settingsDialog.pathExists(settingsDialog.heliconPath) } } @@ -440,28 +513,30 @@ Window { sourceComponent: styledTextField Layout.fillWidth: true onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.photoshopPath - item.textEdited.connect(function() { settingsDialog.photoshopPath = item.text }) + settingsDialog.setLoaderProperty(photoshopField, "text", settingsDialog.photoshopPath) + settingsDialog.connectLoaderSignal(photoshopField, "textEdited", function() { + settingsDialog.photoshopPath = settingsDialog.loaderProperty(photoshopField, "text", settingsDialog.photoshopPath) + }) } } Button { + id: photoshopBrowseButton text: "Browse" flat: true onClicked: { - var path = uiState.open_file_dialog() + var path = settingsDialog.openFileDialog() if (path) { settingsDialog.photoshopPath = path - if (photoshopField.item) photoshopField.item.text = path + settingsDialog.setLoaderProperty(photoshopField, "text", path) } } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: photoshopBrowseButton.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: photoshopBrowseButton.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } } Label { text: "✔" color: "#4ade80" - visible: uiState && uiState.check_path_exists(settingsDialog.photoshopPath) + visible: settingsDialog.pathExists(settingsDialog.photoshopPath) } } @@ -474,28 +549,30 @@ Window { sourceComponent: styledTextField Layout.fillWidth: true onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.rawtherapeePath - item.textEdited.connect(function() { settingsDialog.rawtherapeePath = item.text }) + settingsDialog.setLoaderProperty(rawtherapeeField, "text", settingsDialog.rawtherapeePath) + settingsDialog.connectLoaderSignal(rawtherapeeField, "textEdited", function() { + settingsDialog.rawtherapeePath = settingsDialog.loaderProperty(rawtherapeeField, "text", settingsDialog.rawtherapeePath) + }) } } Button { + id: rawtherapeeBrowseButton text: "Browse" flat: true onClicked: { - var path = uiState.open_file_dialog() + var path = settingsDialog.openFileDialog() if (path) { settingsDialog.rawtherapeePath = path - if (rawtherapeeField.item) rawtherapeeField.item.text = path + settingsDialog.setLoaderProperty(rawtherapeeField, "text", path) } } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: rawtherapeeBrowseButton.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: rawtherapeeBrowseButton.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } } Label { text: "✔" color: "#4ade80" - visible: uiState && uiState.check_path_exists(settingsDialog.rawtherapeePath) + visible: settingsDialog.pathExists(settingsDialog.rawtherapeePath) } } @@ -508,23 +585,25 @@ Window { sourceComponent: styledTextField Layout.fillWidth: true onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.defaultDirectory - item.textEdited.connect(function() { settingsDialog.defaultDirectory = item.text }) + settingsDialog.setLoaderProperty(defaultDirField, "text", settingsDialog.defaultDirectory) + settingsDialog.connectLoaderSignal(defaultDirField, "textEdited", function() { + settingsDialog.defaultDirectory = settingsDialog.loaderProperty(defaultDirField, "text", settingsDialog.defaultDirectory) + }) } } Button { + id: defaultDirBrowseButton text: "Browse" flat: true onClicked: { - var path = uiState.open_directory_dialog() + var path = settingsDialog.openDirectoryDialog() if (path) { settingsDialog.defaultDirectory = path - if (defaultDirField.item) defaultDirField.item.text = path + settingsDialog.setLoaderProperty(defaultDirField, "text", path) } } - background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } - contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: defaultDirBrowseButton.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: defaultDirBrowseButton.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } } } @@ -558,17 +637,14 @@ Window { sourceComponent: styledTextField Layout.preferredWidth: 80 onLoaded: { - // Text is set once in onVisibleChanged - item.text = settingsDialog.cacheSize.toFixed(1) - item.editingFinished.connect(function() { - var value = parseFloat(item.text) + settingsDialog.setLoaderProperty(cacheSizeField, "text", settingsDialog.cacheSize.toFixed(1)) + settingsDialog.connectLoaderSignal(cacheSizeField, "editingFinished", function() { + var value = parseFloat(settingsDialog.loaderProperty(cacheSizeField, "text", settingsDialog.cacheSize.toFixed(1))) if (!isNaN(value) && value >= 0.5 && value <= 16) { settingsDialog.cacheSize = value - // Reformat to show consistent precision - item.text = settingsDialog.cacheSize.toFixed(1) + settingsDialog.setLoaderProperty(cacheSizeField, "text", settingsDialog.cacheSize.toFixed(1)) } else { - // Reset to valid value if invalid input - item.text = settingsDialog.cacheSize.toFixed(1) + settingsDialog.setLoaderProperty(cacheSizeField, "text", settingsDialog.cacheSize.toFixed(1)) } }) } @@ -595,11 +671,15 @@ Window { ToolTip.text: "Number of images around the current image to pre-load in the background. Higher values make browsing smoother but use more CPU/RAM. Lower values reduce resource usage. Recommended: 4-8 for smooth navigation." } Loader { + id: prefetchRadiusLoader sourceComponent: styledSpinBox onLoaded: { - item.from = 1; item.to = 20 - item.value = settingsDialog.prefetchRadius - item.valueChanged.connect(function() { settingsDialog.prefetchRadius = item.value }) + settingsDialog.setLoaderProperty(prefetchRadiusLoader, "from", 1) + settingsDialog.setLoaderProperty(prefetchRadiusLoader, "to", 20) + settingsDialog.setLoaderProperty(prefetchRadiusLoader, "value", settingsDialog.prefetchRadius) + settingsDialog.connectLoaderSignal(prefetchRadiusLoader, "valueChanged", function() { + settingsDialog.prefetchRadius = settingsDialog.loaderProperty(prefetchRadiusLoader, "value", settingsDialog.prefetchRadius) + }) } } @@ -618,32 +698,38 @@ Window { ToolTip.text: "Speed: Faster JPEG decoding using hardware acceleration (may have slight quality loss). Quality: Slower but pixel-perfect decoding. Choose Speed for general browsing, Quality for critical image inspection." } ComboBox { + id: optimizeCombo model: ["speed", "quality"] currentIndex: Math.max(0, model.indexOf(settingsDialog.optimizeFor)) onActivated: settingsDialog.optimizeFor = model[currentIndex] Layout.preferredWidth: 150 delegate: ItemDelegate { - width: parent.width - contentItem: Text { text: modelData; color: settingsDialog.textColor; font: parent.font; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + id: optimizeOption + required property string modelData + width: optimizeCombo.width + contentItem: Text { text: optimizeOption.modelData; color: settingsDialog.textColor; font: optimizeOption.font; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: optimizeOption.highlighted ? "#20ffffff" : "transparent" } } - contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + contentItem: Text { text: optimizeCombo.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } } // Theme Label { text: "Theme"; color: settingsDialog.textColor } ComboBox { + id: themeCombo model: ["Dark", "Light"] currentIndex: settingsDialog.theme onActivated: settingsDialog.theme = currentIndex Layout.preferredWidth: 150 delegate: ItemDelegate { - width: parent.width - contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + id: themeOption + required property string modelData + width: themeCombo.width + contentItem: Text { text: themeOption.modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: themeOption.highlighted ? "#20ffffff" : "transparent" } } - contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + contentItem: Text { text: themeCombo.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } } } @@ -695,18 +781,20 @@ Window { sourceComponent: styledTextField Layout.preferredWidth: 80 onLoaded: { - item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) - item.editingFinished.connect(function() { - var value = parseFloat(item.text) - if (!isNaN(value) && value >= 0.0 && value <= 10.0) settingsDialog.autoLevelClippingThreshold = value - item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + settingsDialog.setLoaderProperty(clipThresholdLoader, "text", settingsDialog.autoLevelClippingThreshold.toFixed(4)) + settingsDialog.connectLoaderSignal(clipThresholdLoader, "editingFinished", function() { + var value = parseFloat(settingsDialog.loaderProperty(clipThresholdLoader, "text", settingsDialog.autoLevelClippingThreshold.toFixed(4))) + if (!isNaN(value) && value >= 0.0 && value <= 10.0) { + settingsDialog.autoLevelClippingThreshold = value + } + settingsDialog.setLoaderProperty(clipThresholdLoader, "text", settingsDialog.autoLevelClippingThreshold.toFixed(4)) }) } Binding { target: clipThresholdLoader.item property: "text" value: settingsDialog.autoLevelClippingThreshold.toFixed(4) - when: clipThresholdLoader.item && !clipThresholdLoader.item.activeFocus + when: clipThresholdLoader.item && !settingsDialog.loaderProperty(clipThresholdLoader, "activeFocus", false) } } @@ -730,17 +818,21 @@ Window { sourceComponent: styledSlider Layout.fillWidth: true onLoaded: { - item.from = 0.0; item.to = 1.0; item.stepSize = 0.05 - item.value = settingsDialog.autoLevelStrength - item.valueChanged.connect(function() { settingsDialog.autoLevelStrength = item.value }) - item.enabled = Qt.binding(function() { return !autoLvlAuto.checked }) - item.opacity = Qt.binding(function() { return (!autoLvlAuto.checked) ? 1.0 : 0.5 }) + settingsDialog.setLoaderProperty(autoLevelStrengthLoader, "from", 0.0) + settingsDialog.setLoaderProperty(autoLevelStrengthLoader, "to", 1.0) + settingsDialog.setLoaderProperty(autoLevelStrengthLoader, "stepSize", 0.05) + settingsDialog.setLoaderProperty(autoLevelStrengthLoader, "value", settingsDialog.autoLevelStrength) + settingsDialog.connectLoaderSignal(autoLevelStrengthLoader, "valueChanged", function() { + settingsDialog.autoLevelStrength = settingsDialog.loaderProperty(autoLevelStrengthLoader, "value", settingsDialog.autoLevelStrength) + }) + settingsDialog.setLoaderBinding(autoLevelStrengthLoader, "enabled", function() { return !autoLvlAuto.checked }) + settingsDialog.setLoaderBinding(autoLevelStrengthLoader, "opacity", function() { return autoLvlAuto.checked ? 0.5 : 1.0 }) } Binding { target: autoLevelStrengthLoader.item property: "value" value: settingsDialog.autoLevelStrength - when: autoLevelStrengthLoader.item && !autoLevelStrengthLoader.item.pressed + when: autoLevelStrengthLoader.item && !settingsDialog.loaderProperty(autoLevelStrengthLoader, "pressed", false) } } CheckBox { @@ -748,14 +840,14 @@ Window { text: "Auto" checked: settingsDialog.autoLevelStrengthAuto onCheckedChanged: settingsDialog.autoLevelStrengthAuto = checked - contentItem: Text { text: parent.text; color: settingsDialog.textColor; leftPadding: parent.indicator.width + parent.spacing; verticalAlignment: Text.AlignVCenter } + contentItem: Text { text: autoLvlAuto.text; color: settingsDialog.textColor; leftPadding: autoLvlAuto.indicator.width + autoLvlAuto.spacing; verticalAlignment: Text.AlignVCenter } indicator: Rectangle { implicitWidth: 18; implicitHeight: 18 - x: parent.leftPadding; y: parent.height / 2 - height / 2 + x: autoLvlAuto.leftPadding; y: parent.height / 2 - height / 2 radius: 3 border.color: settingsDialog.accentColor - color: parent.checked ? settingsDialog.accentColor : "transparent" - Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: parent.parent.checked; font.bold: true } + color: autoLvlAuto.checked ? settingsDialog.accentColor : "transparent" + Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: autoLvlAuto.checked; font.bold: true } } } } @@ -790,22 +882,25 @@ Window { ToolTip.text: "Algorithm for auto white balance. 'lab' analyzes in LAB color space for perceptually uniform results. 'rgb' works directly in RGB space. Most users should use 'lab'." } ComboBox { + id: awbModeCombo model: ["lab", "rgb"] currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) onActivated: settingsDialog.awbMode = model[currentIndex] Layout.preferredWidth: 150 delegate: ItemDelegate { - width: parent.width - contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } - background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + id: awbModeOption + required property string modelData + width: awbModeCombo.width + contentItem: Text { text: awbModeOption.modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: awbModeOption.highlighted ? "#20ffffff" : "transparent" } } - contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + contentItem: Text { text: awbModeCombo.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } } // Strength Label { - text: "Strength (" + (awbStrSlider.item ? Math.round(awbStrSlider.item.value * 100) : 0) + "%)" + text: "Strength (" + Math.round(settingsDialog.loaderProperty(awbStrSlider, "value", 0) * 100) + "%)" color: settingsDialog.textColor MouseArea { @@ -822,15 +917,18 @@ Window { sourceComponent: styledSlider Layout.fillWidth: true onLoaded: { - item.from = 0.3; item.to = 1.0 - item.value = settingsDialog.awbStrength - item.valueChanged.connect(function() { settingsDialog.awbStrength = item.value }) + settingsDialog.setLoaderProperty(awbStrSlider, "from", 0.3) + settingsDialog.setLoaderProperty(awbStrSlider, "to", 1.0) + settingsDialog.setLoaderProperty(awbStrSlider, "value", settingsDialog.awbStrength) + settingsDialog.connectLoaderSignal(awbStrSlider, "valueChanged", function() { + settingsDialog.awbStrength = settingsDialog.loaderProperty(awbStrSlider, "value", settingsDialog.awbStrength) + }) } Binding { target: awbStrSlider.item property: "value" value: settingsDialog.awbStrength - when: awbStrSlider.item && !awbStrSlider.item.pressed + when: awbStrSlider.item && !settingsDialog.loaderProperty(awbStrSlider, "pressed", false) } } @@ -852,15 +950,18 @@ Window { id: awbWarmBiasLoader sourceComponent: styledSpinBox onLoaded: { - item.from = -50; item.to = 50 - item.value = settingsDialog.awbWarmBias - item.valueChanged.connect(function() { settingsDialog.awbWarmBias = item.value }) + settingsDialog.setLoaderProperty(awbWarmBiasLoader, "from", -50) + settingsDialog.setLoaderProperty(awbWarmBiasLoader, "to", 50) + settingsDialog.setLoaderProperty(awbWarmBiasLoader, "value", settingsDialog.awbWarmBias) + settingsDialog.connectLoaderSignal(awbWarmBiasLoader, "valueChanged", function() { + settingsDialog.awbWarmBias = settingsDialog.loaderProperty(awbWarmBiasLoader, "value", settingsDialog.awbWarmBias) + }) } Binding { target: awbWarmBiasLoader.item property: "value" value: settingsDialog.awbWarmBias - when: awbWarmBiasLoader.item && !awbWarmBiasLoader.item.activeFocus + when: awbWarmBiasLoader.item && !settingsDialog.loaderProperty(awbWarmBiasLoader, "activeFocus", false) } } @@ -882,9 +983,12 @@ Window { id: awbTintBiasLoader sourceComponent: styledSpinBox onLoaded: { - item.from = -50; item.to = 50 - item.value = settingsDialog.awbTintBias - item.valueChanged.connect(function() { settingsDialog.awbTintBias = item.value }) + settingsDialog.setLoaderProperty(awbTintBiasLoader, "from", -50) + settingsDialog.setLoaderProperty(awbTintBiasLoader, "to", 50) + settingsDialog.setLoaderProperty(awbTintBiasLoader, "value", settingsDialog.awbTintBias) + settingsDialog.connectLoaderSignal(awbTintBiasLoader, "valueChanged", function() { + settingsDialog.awbTintBias = settingsDialog.loaderProperty(awbTintBiasLoader, "value", settingsDialog.awbTintBias) + }) } Binding { target: awbTintBiasLoader.item @@ -924,7 +1028,14 @@ Window { Loader { id: awbLumaLowerLoader sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaLowerBound=item.value})} + onLoaded: { + settingsDialog.setLoaderProperty(awbLumaLowerLoader, "from", 0) + settingsDialog.setLoaderProperty(awbLumaLowerLoader, "to", 255) + settingsDialog.setLoaderProperty(awbLumaLowerLoader, "value", settingsDialog.awbLumaLowerBound) + settingsDialog.connectLoaderSignal(awbLumaLowerLoader, "valueChanged", function() { + settingsDialog.awbLumaLowerBound = settingsDialog.loaderProperty(awbLumaLowerLoader, "value", settingsDialog.awbLumaLowerBound) + }) + } Binding { target: awbLumaLowerLoader.item property: "value" @@ -949,7 +1060,14 @@ Window { Loader { id: awbLumaUpperLoader sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaUpperBound=item.value})} + onLoaded: { + settingsDialog.setLoaderProperty(awbLumaUpperLoader, "from", 0) + settingsDialog.setLoaderProperty(awbLumaUpperLoader, "to", 255) + settingsDialog.setLoaderProperty(awbLumaUpperLoader, "value", settingsDialog.awbLumaUpperBound) + settingsDialog.connectLoaderSignal(awbLumaUpperLoader, "valueChanged", function() { + settingsDialog.awbLumaUpperBound = settingsDialog.loaderProperty(awbLumaUpperLoader, "value", settingsDialog.awbLumaUpperBound) + }) + } Binding { target: awbLumaUpperLoader.item property: "value" @@ -974,7 +1092,14 @@ Window { Loader { id: awbRgbLowerLoader sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbLowerBound=item.value})} + onLoaded: { + settingsDialog.setLoaderProperty(awbRgbLowerLoader, "from", 0) + settingsDialog.setLoaderProperty(awbRgbLowerLoader, "to", 255) + settingsDialog.setLoaderProperty(awbRgbLowerLoader, "value", settingsDialog.awbRgbLowerBound) + settingsDialog.connectLoaderSignal(awbRgbLowerLoader, "valueChanged", function() { + settingsDialog.awbRgbLowerBound = settingsDialog.loaderProperty(awbRgbLowerLoader, "value", settingsDialog.awbRgbLowerBound) + }) + } Binding { target: awbRgbLowerLoader.item property: "value" @@ -999,7 +1124,14 @@ Window { Loader { id: awbRgbUpperLoader sourceComponent: styledSpinBox - onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbUpperBound=item.value})} + onLoaded: { + settingsDialog.setLoaderProperty(awbRgbUpperLoader, "from", 0) + settingsDialog.setLoaderProperty(awbRgbUpperLoader, "to", 255) + settingsDialog.setLoaderProperty(awbRgbUpperLoader, "value", settingsDialog.awbRgbUpperBound) + settingsDialog.connectLoaderSignal(awbRgbUpperLoader, "valueChanged", function() { + settingsDialog.awbRgbUpperBound = settingsDialog.loaderProperty(awbRgbUpperLoader, "value", settingsDialog.awbRgbUpperBound) + }) + } Binding { target: awbRgbUpperLoader.item property: "value" @@ -1035,39 +1167,41 @@ Window { Item { Layout.fillWidth: true } // Spacer left Button { + id: cancelButton text: "Cancel" Layout.preferredWidth: 100 onClicked: settingsDialog.visible = false contentItem: Text { - text: parent.text - font: parent.font + text: cancelButton.text + font: cancelButton.font color: settingsDialog.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.pressed ? "#40ffffff" : "#20ffffff" + color: cancelButton.pressed ? "#40ffffff" : "#20ffffff" radius: 4 - border.color: parent.hovered ? "#60ffffff" : "transparent" + border.color: cancelButton.hovered ? "#60ffffff" : "transparent" } } Button { + id: saveButton text: "Save" Layout.preferredWidth: 100 highlighted: true onClicked: settingsDialog.saveSettings() contentItem: Text { - text: parent.text - font: parent.font + text: saveButton.text + font: saveButton.font color: "white" horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { - color: parent.pressed ? Qt.darker(settingsDialog.accentColor, 1.1) : settingsDialog.accentColor + color: saveButton.pressed ? Qt.darker(settingsDialog.accentColor, 1.1) : settingsDialog.accentColor radius: 4 } } @@ -1080,7 +1214,7 @@ Window { repeat: true running: false onTriggered: { - if (uiState) settingsDialog.cacheUsage = uiState.get_cache_usage_gb() + if (settingsDialog.uiStateRef) settingsDialog.cacheUsage = settingsDialog.uiStateRef.get_cache_usage_gb() } } } diff --git a/faststack/qml/ThumbnailGridView.qml b/faststack/qml/ThumbnailGridView.qml index 90df553..d24de7c 100644 --- a/faststack/qml/ThumbnailGridView.qml +++ b/faststack/qml/ThumbnailGridView.qml @@ -1,6 +1,7 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Controls -import QtQuick.Layouts // Main grid view for thumbnail browser Item { @@ -9,13 +10,15 @@ Item { // Theme property (bound by parent) property bool isDarkTheme: false + property var uiStateRef: typeof uiState !== "undefined" ? uiState : null + property var thumbnailModelRef: typeof thumbnailModel !== "undefined" ? thumbnailModel : null // Configuration property int cellWidth: 190 property int cellHeight: 210 // Selection count for keyboard handler (use gridSelectedCount for efficiency) - property int selectedCount: uiState ? uiState.gridSelectedCount : 0 + property int selectedCount: gridViewRoot.uiStateRef ? gridViewRoot.uiStateRef.gridSelectedCount : 0 // Wrapper to expose function to Loader function setPrefetchEnabled(enabled) { @@ -39,7 +42,7 @@ Item { highlightFollowsCurrentItem: true currentIndex: 0 // Track cursor position - model: thumbnailModel + model: gridViewRoot.thumbnailModelRef delegate: ThumbnailTile { width: thumbnailGrid.cellWidth - 10 @@ -48,26 +51,6 @@ Item { // Theme binding from parent isDarkTheme: gridViewRoot.isDarkTheme - // Model role bindings - use attached property 'index' directly - // Model roles become context properties in delegate - tileIndex: index - tileFilePath: filePath || "" - tileFileName: fileName || "" - tileIsFolder: isFolder || false - tileIsStacked: isStacked || false - tileIsUploaded: isUploaded || false - tileIsEdited: isEdited || false - tileIsRestacked: isRestacked || false - tileIsFavorite: isFavorite || false - tileIsTodo: isTodo || false - tileIsInBatch: isInBatch || false - tileIsCurrent: isCurrent || false - tileThumbnailSource: thumbnailSource || "" - tileFolderStats: folderStats || null - tileIsSelected: isSelected || false - tileIsParentFolder: isParentFolder || false - tileHasBackups: hasBackups || false - tileHasDeveloped: hasDeveloped || false tileHasCursor: index === thumbnailGrid.currentIndex } @@ -96,7 +79,7 @@ Item { } else { prefetchTimer.stop() // Cancel any queued work immediately to clear the backlog - if (uiState) uiState.cancelThumbnailPrefetch() + if (gridViewRoot.uiStateRef) gridViewRoot.uiStateRef.cancelThumbnailPrefetch() } } @@ -115,7 +98,7 @@ Item { function triggerPrefetch() { if (!prefetchEnabled) return - if (!uiState || thumbnailGrid.count === 0) return + if (!gridViewRoot.uiStateRef || thumbnailGrid.count === 0) return var cellW = thumbnailGrid.cellWidth var cellH = thumbnailGrid.cellHeight @@ -144,13 +127,13 @@ Item { maxCount = Math.max(200, Math.min(maxCount, 800)) // Log for debugging - if (uiState && uiState.debugMode) { + if (gridViewRoot.uiStateRef && gridViewRoot.uiStateRef.debugMode) { console.log("Prefetch range:", topIndex, "-", bottomIndex, "maxCount=" + maxCount + " cols=" + cols) } // Actually trigger prefetch - if (uiState) { - uiState.gridPrefetchRange(topIndex, bottomIndex, maxCount) + if (gridViewRoot.uiStateRef) { + gridViewRoot.uiStateRef.gridPrefetchRange(topIndex, bottomIndex, maxCount) } } @@ -169,7 +152,7 @@ Item { // Empty state Text { anchors.centerIn: parent - visible: thumbnailGrid.count === 0 && uiState && uiState.isFolderLoaded + visible: thumbnailGrid.count === 0 && gridViewRoot.uiStateRef && gridViewRoot.uiStateRef.isFolderLoaded text: "No images in this folder" color: gridViewRoot.isDarkTheme ? "#888888" : "#666666" font.pixelSize: 16 @@ -177,7 +160,7 @@ Item { // Keyboard shortcuts (inside GridView so it receives focus) Keys.onPressed: function(event) { - if (!uiState) return + if (!gridViewRoot.uiStateRef) return // Calculate columns with epsilon to handle rounding issues during window resizing var cols = Math.max(1, Math.floor((thumbnailGrid.width + 1) / thumbnailGrid.cellWidth)) @@ -185,9 +168,9 @@ Item { if (event.key === Qt.Key_Escape) { // Clear selection or switch to loupe if (gridViewRoot.selectedCount > 0) { - uiState.gridClearSelection() + gridViewRoot.uiStateRef.gridClearSelection() } else { - uiState.toggleGridView() + gridViewRoot.uiStateRef.toggleGridView() } event.accepted = true } else if (event.key === Qt.Key_Left) { @@ -222,19 +205,19 @@ Item { event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { // Open current item in loupe view (or navigate into folder) - uiState.gridOpenIndex(thumbnailGrid.currentIndex) + gridViewRoot.uiStateRef.gridOpenIndex(thumbnailGrid.currentIndex) event.accepted = true } else if (event.key === Qt.Key_Space) { // Toggle selection on current item - uiState.gridSelectIndex(thumbnailGrid.currentIndex, false, true) + gridViewRoot.uiStateRef.gridSelectIndex(thumbnailGrid.currentIndex, false, true) event.accepted = true } else if (event.key === Qt.Key_B) { // Add selected images to batch - uiState.gridAddSelectionToBatch() + gridViewRoot.uiStateRef.gridAddSelectionToBatch() event.accepted = true } else if (event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) { // Delete selected images or cursor image - uiState.gridDeleteAtCursor(thumbnailGrid.currentIndex) + gridViewRoot.uiStateRef.gridDeleteAtCursor(thumbnailGrid.currentIndex) event.accepted = true } } @@ -245,22 +228,22 @@ Item { onHeightChanged: { if (thumbnailGrid.prefetchEnabled) prefetchTimer.restart() } Component.onCompleted: { - if (uiState && uiState.debugThumbTiming) + if (gridViewRoot.uiStateRef && gridViewRoot.uiStateRef.debugThumbTiming) console.log("[THUMB-TIMING] GridView Component.onCompleted t=" + Date.now() + "ms") thumbnailGrid.forceActiveFocus() // Sync initial cursor position from state to prevent top-of-list prefetch - if (uiState && uiState.currentIndex >= 0 && uiState.currentIndex < thumbnailGrid.count) { - thumbnailGrid.currentIndex = uiState.currentIndex + if (gridViewRoot.uiStateRef && gridViewRoot.uiStateRef.currentIndex >= 0 && gridViewRoot.uiStateRef.currentIndex < thumbnailGrid.count) { + thumbnailGrid.currentIndex = gridViewRoot.uiStateRef.currentIndex thumbnailGrid.positionViewAtIndex(thumbnailGrid.currentIndex, GridView.Center) } } Connections { - target: uiState + target: gridViewRoot.uiStateRef function onIsGridViewActiveChanged() { - if (uiState.isGridViewActive) { + if (gridViewRoot.uiStateRef.isGridViewActive) { // Prefetch triggering is now handled by Main.qml via setPrefetchEnabled // to avoid transient state issues. thumbnailGrid.forceActiveFocus() diff --git a/faststack/qml/ThumbnailTile.qml b/faststack/qml/ThumbnailTile.qml index 37638d4..79e9a87 100644 --- a/faststack/qml/ThumbnailTile.qml +++ b/faststack/qml/ThumbnailTile.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -6,29 +8,50 @@ import QtQuick.Layouts Item { id: tile - // Properties from model (prefixed to avoid shadowing model roles) - property int tileIndex: 0 - property string tileFilePath: "" - property string tileFileName: "" - property bool tileIsFolder: false - property bool tileIsStacked: false - property bool tileIsUploaded: false - property bool tileIsEdited: false - property bool tileIsRestacked: false - property bool tileIsFavorite: false - property bool tileIsTodo: false - property bool tileIsInBatch: false - property bool tileIsCurrent: false - property string tileThumbnailSource: "" - property var tileFolderStats: null - property bool tileIsSelected: false - property bool tileIsParentFolder: false + // Required delegate properties from the model. + required property int index + required property string filePath + required property string fileName + required property bool isFolder + required property bool isStacked + required property bool isUploaded + required property bool isEdited + required property bool isRestacked + required property bool isFavorite + required property bool isTodo + required property bool isInBatch + required property bool isCurrent + required property string thumbnailSource + required property var folderStats + required property bool isSelected + required property bool isParentFolder + required property bool hasBackups + required property bool hasDeveloped + + // Prefixed mirror properties to keep the rest of the file stable. + property int tileIndex: index + property string tileFilePath: filePath || "" + property string tileFileName: fileName || "" + property bool tileIsFolder: isFolder || false + property bool tileIsStacked: isStacked || false + property bool tileIsUploaded: isUploaded || false + property bool tileIsEdited: isEdited || false + property bool tileIsRestacked: isRestacked || false + property bool tileIsFavorite: isFavorite || false + property bool tileIsTodo: isTodo || false + property bool tileIsInBatch: isInBatch || false + property bool tileIsCurrent: isCurrent || false + property string tileThumbnailSource: thumbnailSource || "" + property var tileFolderStats: folderStats || null + property bool tileIsSelected: isSelected || false + property bool tileIsParentFolder: isParentFolder || false property bool tileHasCursor: false // Keyboard cursor position - property bool tileHasBackups: false - property bool tileHasDeveloped: false + property bool tileHasBackups: hasBackups || false + property bool tileHasDeveloped: hasDeveloped || false // Theme property (bound by parent) property bool isDarkTheme: false + property var uiStateRef: typeof uiState !== "undefined" ? uiState : null // Configuration property int tileSize: 180 @@ -65,29 +88,29 @@ Item { anchors.fill: parent color: { if (tile.tileIsCurrent && !tile.tileIsFolder) { - return Qt.rgba(currentColor.r, currentColor.g, currentColor.b, 0.25) + return Qt.rgba(tile.currentColor.r, tile.currentColor.g, tile.currentColor.b, 0.25) } else if (tile.tileIsSelected) { - return Qt.rgba(selectedColor.r, selectedColor.g, selectedColor.b, 0.3) + return Qt.rgba(tile.selectedColor.r, tile.selectedColor.g, tile.selectedColor.b, 0.3) } else if (tile.tileHasCursor) { - return Qt.rgba(cursorColor.r, cursorColor.g, cursorColor.b, 0.15) + return Qt.rgba(tile.cursorColor.r, tile.cursorColor.g, tile.cursorColor.b, 0.15) } else if (tileMouseArea.containsMouse) { - return hoverColor + return tile.hoverColor } if (tile.tileIsFolder) { return tile.isDarkTheme ? "#181818" : "#f0f0f0" } - return backgroundColor + return tile.backgroundColor } radius: 4 // Border - current gets gold, selected gets green, cursor gets cyan border.color: { if (tile.tileIsCurrent && !tile.tileIsFolder) { - return currentColor + return tile.currentColor } else if (tile.tileIsSelected) { - return selectedColor + return tile.selectedColor } else if (tile.tileHasCursor) { - return cursorColor + return tile.cursorColor } return "transparent" } @@ -103,7 +126,7 @@ Item { // Thumbnail container Item { Layout.fillWidth: true - Layout.preferredHeight: thumbnailSize + Layout.preferredHeight: tile.thumbnailSize Layout.alignment: Qt.AlignHCenter // Thumbnail image @@ -111,8 +134,8 @@ Item { id: thumbnailImage anchors.centerIn: parent visible: !tile.tileIsFolder - width: Math.min(thumbnailSize, parent.width) - height: Math.min(thumbnailSize, parent.height) + width: Math.min(tile.thumbnailSize, parent.width) + height: Math.min(tile.thumbnailSize, parent.height) fillMode: Image.PreserveAspectFit source: tile.tileIsFolder ? "" : tile.tileThumbnailSource asynchronous: true @@ -149,7 +172,7 @@ Item { visible: tile.tileIsParentFolder text: "\u2B06" // Up arrow font.pixelSize: 48 - color: textColor + color: tile.textColor opacity: 0.8 } @@ -167,7 +190,7 @@ Item { width: 18 height: 18 radius: 3 - color: uploadedColor + color: tile.uploadedColor Text { anchors.centerIn: parent text: "U" @@ -183,7 +206,7 @@ Item { width: 18 height: 18 radius: 3 - color: editedColor + color: tile.editedColor Text { anchors.centerIn: parent text: "E" @@ -199,7 +222,7 @@ Item { width: 18 height: 18 radius: 3 - color: restackedColor + color: tile.restackedColor Text { anchors.centerIn: parent text: "R" @@ -215,7 +238,7 @@ Item { width: 18 height: 18 radius: 3 - color: todoColor + color: tile.todoColor Text { anchors.centerIn: parent text: "D" @@ -231,7 +254,7 @@ Item { width: 18 height: 18 radius: 3 - color: favoriteColor + color: tile.favoriteColor Text { anchors.centerIn: parent text: "F" @@ -247,7 +270,7 @@ Item { width: 18 height: 18 radius: 3 - color: batchColor + color: tile.batchColor Text { anchors.centerIn: parent text: "B" @@ -263,7 +286,7 @@ Item { width: 18 height: 18 radius: 3 - color: stackedColor + color: tile.stackedColor Text { anchors.centerIn: parent text: "S" @@ -289,7 +312,7 @@ Item { width: 18 height: 18 radius: 3 - color: backupsColor + color: tile.backupsColor Text { anchors.centerIn: parent text: "Bk" @@ -305,7 +328,7 @@ Item { width: 18 height: 18 radius: 3 - color: developedColor + color: tile.developedColor Text { anchors.centerIn: parent text: "D" @@ -466,6 +489,7 @@ Item { Repeater { model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] delegate: Rectangle { + required property var modelData width: 3 height: 3 radius: 0.5 @@ -482,6 +506,7 @@ Item { Repeater { model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] delegate: Rectangle { + required property var modelData width: 3 height: 3 radius: 0.5 @@ -497,6 +522,7 @@ Item { Repeater { model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] delegate: Rectangle { + required property var modelData width: 3 height: 3 radius: 0.5 @@ -512,6 +538,7 @@ Item { Repeater { model: tile.tileFolderStats ? tile.tileFolderStats.coverage_buckets : [] delegate: Rectangle { + required property var modelData width: 3 height: 3 radius: 0.5 @@ -571,9 +598,9 @@ Item { // Filename text Text { Layout.fillWidth: true - Layout.preferredHeight: textHeight + Layout.preferredHeight: tile.textHeight text: tile.tileIsParentFolder ? "(Parent Folder)" : tile.tileFileName - color: textColor + color: tile.textColor font.pixelSize: 11 elide: Text.ElideMiddle horizontalAlignment: Text.AlignHCenter @@ -582,9 +609,7 @@ Item { } Component.onCompleted: { - // Use robust check for uiState which might not be defined in all contexts - var hasUiState = (typeof uiState !== 'undefined' && uiState !== null); - if (tile.tileIndex === 0 && hasUiState && uiState.debugThumbTiming) + if (tile.tileIndex === 0 && tile.uiStateRef && tile.uiStateRef.debugThumbTiming) console.log("[THUMB-TIMING] first delegate created (index 0) t=" + Date.now() + "ms") } @@ -598,7 +623,7 @@ Item { onClicked: function(mouse) { if (tile.tileIsFolder) { // Navigate into folder (or parent) - uiState.gridNavigateTo(tile.tileFilePath) + if (tile.uiStateRef) tile.uiStateRef.gridNavigateTo(tile.tileFilePath) } else { // Handle selection or opening var hasShift = (mouse.modifiers & Qt.ShiftModifier) @@ -607,13 +632,13 @@ Item { if (isRightClick) { // Right-click: toggle selection (as per help text) - uiState.gridSelectIndex(tile.tileIndex, false, true) + if (tile.uiStateRef) tile.uiStateRef.gridSelectIndex(tile.tileIndex, false, true) } else if (hasShift || hasCtrl) { // Shift: range select, Ctrl: add to selection - uiState.gridSelectIndex(tile.tileIndex, hasShift, hasCtrl) + if (tile.uiStateRef) tile.uiStateRef.gridSelectIndex(tile.tileIndex, hasShift, hasCtrl) } else { // Left-click without modifiers: open in loupe view - uiState.gridOpenIndex(tile.tileIndex) + if (tile.uiStateRef) tile.uiStateRef.gridOpenIndex(tile.tileIndex) } } } diff --git a/faststack/tests/thumbnail_view/test_qml_delegate_contract.py b/faststack/tests/thumbnail_view/test_qml_delegate_contract.py new file mode 100644 index 0000000..28a1d79 --- /dev/null +++ b/faststack/tests/thumbnail_view/test_qml_delegate_contract.py @@ -0,0 +1,236 @@ +"""Contract tests between ThumbnailModel roles and ThumbnailTile QML.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import pytest +from PySide6.QtCore import QCoreApplication + +from faststack.thumbnail_view.folder_stats import FolderStats +from faststack.thumbnail_view.model import ThumbnailEntry, ThumbnailModel + + +@pytest.fixture(scope="module") +def qapp(): + """Ensure a Qt core app exists for model instances.""" + app = QCoreApplication.instance() + if app is None: + app = QCoreApplication(sys.argv) + yield app + + +def _thumbnail_tile_required_properties() -> set[str]: + qml_path = Path(__file__).resolve().parents[2] / "qml" / "ThumbnailTile.qml" + qml_text = qml_path.read_text(encoding="utf-8") + sanitized_text = _sanitize_qml(qml_text) + root_body = _extract_tile_root_body(qml_text, sanitized_text) + sanitized_root_body = _extract_tile_root_body(sanitized_text, sanitized_text) + + required_props: set[str] = set() + depth = 0 + for raw_line, sanitized_line in zip( + root_body.splitlines(), sanitized_root_body.splitlines() + ): + if depth == 0: + match = re.fullmatch(r"\s*required property \w+ (\w+)\s*", raw_line) + if match: + required_props.add(match.group(1)) + depth += sanitized_line.count("{") - sanitized_line.count("}") + + return required_props + + +def _sanitize_qml(qml_text: str) -> str: + """Strip strings and comments while preserving braces and newlines.""" + out: list[str] = [] + i = 0 + in_line_comment = False + in_block_comment = False + in_string: str | None = None + + while i < len(qml_text): + ch = qml_text[i] + nxt = qml_text[i + 1] if i + 1 < len(qml_text) else "" + + if in_line_comment: + if ch == "\n": + in_line_comment = False + out.append(ch) + else: + out.append(" ") + i += 1 + continue + + if in_block_comment: + if ch == "*" and nxt == "/": + # Replace the two closing-comment characters one-for-one so + # brace positions still line up with the original source. + out.extend(" ") + in_block_comment = False + i += 2 + else: + out.append("\n" if ch == "\n" else " ") + i += 1 + continue + + if in_string is not None: + if ch == "\\" and nxt: + # Preserve character count for escaped pairs as well. + out.extend(" ") + i += 2 + elif ch == in_string: + out.append(" ") + in_string = None + i += 1 + else: + out.append("\n" if ch == "\n" else " ") + i += 1 + continue + + if ch == "/" and nxt == "/": + # Replace both comment opener chars to keep indices aligned. + out.extend(" ") + in_line_comment = True + i += 2 + continue + + if ch == "/" and nxt == "*": + # Replace both comment opener chars to keep indices aligned. + out.extend(" ") + in_block_comment = True + i += 2 + continue + + if ch in {"'", '"'}: + out.append(" ") + in_string = ch + i += 1 + continue + + out.append(ch) + i += 1 + + return "".join(out) + + +def _extract_tile_root_body(qml_text: str, sanitized_text: str) -> str: + """Extract the body of the root `Item { ... }` with `id: tile`. + + This relies on the current ThumbnailTile.qml structure and normal QML + convention that `id: tile` is declared directly in the root Item body + before any nested child blocks. + """ + tile_id_pos = sanitized_text.find("id: tile") + assert tile_id_pos != -1, "Could not find `id: tile` in ThumbnailTile.qml" + + # Find the Item body that owns `id: tile`. For the current file, the + # nearest preceding '{' is the root Item opening brace. + open_brace_pos = sanitized_text.rfind("{", 0, tile_id_pos) + assert open_brace_pos != -1, "Could not find root Item opening brace" + + item_pos = sanitized_text.rfind("Item", 0, open_brace_pos) + assert item_pos != -1, "Could not find root Item declaration" + + depth = 1 + close_brace_pos = open_brace_pos + 1 + while close_brace_pos < len(sanitized_text) and depth > 0: + if sanitized_text[close_brace_pos] == "{": + depth += 1 + elif sanitized_text[close_brace_pos] == "}": + depth -= 1 + close_brace_pos += 1 + + assert depth == 0, "Could not find matching root Item closing brace" + return qml_text[open_brace_pos + 1 : close_brace_pos - 1] + + +def _role_ids_by_name(model: ThumbnailModel) -> dict[str, int]: + return {name.decode("utf-8"): role for role, name in model.roleNames().items()} + + +def test_thumbnail_tile_required_roles_exist_on_model(tmp_path, qapp): + """Every required ThumbnailTile delegate role should be defined by the model.""" + model = ThumbnailModel( + base_directory=tmp_path, + current_directory=tmp_path, + get_metadata_callback=None, + ) + + required_props = _thumbnail_tile_required_properties() + # `index` is injected by GridView itself, not by ThumbnailModel.roleNames(). + required_model_roles = required_props - {"index"} + + model_role_names = set(_role_ids_by_name(model)) + missing_roles = required_model_roles - model_role_names + + assert missing_roles == set() + + +def test_thumbnail_tile_required_roles_have_values_for_all_entry_kinds(tmp_path, qapp): + """Image, folder, and synthetic parent rows should all satisfy delegate requirements.""" + model = ThumbnailModel( + base_directory=tmp_path, + current_directory=tmp_path, + get_metadata_callback=None, + ) + + folder_stats = FolderStats( + total_images=4, + stacked_count=1, + uploaded_count=2, + edited_count=1, + jpg_count=3, + raw_count=1, + coverage_buckets=[(1.0, 0.5, 0.25, 0.0)], + ) + parent_entry = ThumbnailEntry( + path=tmp_path.parent, + name="..", + is_folder=True, + mtime_ns=0, + ) + folder_entry = ThumbnailEntry( + path=tmp_path / "child", + name="child", + is_folder=True, + folder_stats=folder_stats, + mtime_ns=123, + ) + image_entry = ThumbnailEntry( + path=tmp_path / "photo.jpg", + name="photo.jpg", + is_folder=False, + is_stacked=True, + is_uploaded=True, + is_edited=True, + is_restacked=True, + is_favorite=True, + is_todo=True, + has_backups=True, + has_developed=True, + mtime_ns=456, + ) + model._entries = [parent_entry, folder_entry, image_entry] + + role_ids = _role_ids_by_name(model) + required_props = _thumbnail_tile_required_properties() - {"index"} + + for row in range(len(model._entries)): + index = model.index(row, 0) + for role_name in required_props: + value = model.data(index, role_ids[role_name]) + assert value is not None, f"row {row} missing value for role {role_name}" + + image_folder_stats = model.data(model.index(2, 0), role_ids["folderStats"]) + assert image_folder_stats == { + "total_images": 0, + "stacked_count": 0, + "uploaded_count": 0, + "edited_count": 0, + "jpg_count": 0, + "raw_count": 0, + "coverage_buckets": [], + } diff --git a/faststack/thumbnail_view/model.py b/faststack/thumbnail_view/model.py index 342fbd8..926a2d0 100644 --- a/faststack/thumbnail_view/model.py +++ b/faststack/thumbnail_view/model.py @@ -29,6 +29,19 @@ log = logging.getLogger(__name__) +def _empty_folder_stats_payload() -> dict: + """Return a stable empty payload for folderStats delegate consumers.""" + return { + "total_images": 0, + "stacked_count": 0, + "uploaded_count": 0, + "edited_count": 0, + "jpg_count": 0, + "raw_count": 0, + "coverage_buckets": [], + } + + def _is_filesystem_root(path: Path) -> bool: r"""Check if a path is a filesystem root. @@ -221,7 +234,7 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): list(t) for t in entry.folder_stats.coverage_buckets ], } - return None + return _empty_folder_stats_payload() elif role == self.IsSelectedRole: return row in self._selected_indices elif role == self.ThumbRevRole: