pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Hyprland import ZShell.Components import ZShell.Config import qs.Components import qs.Helpers import qs.Services Item { id: wrapper property int buttonRowHeight: Tokens.padding.large * 3 readonly property bool changesMade: { cropRevision; const r = cropRectLoader.item; if (!r || !currentScreen || scaledImg.status !== Image.Ready) return false; const t = r.savedTarget(); return Math.abs(r.x - t.x) > 0.5 || Math.abs(r.y - t.y) > 0.5 || Math.abs(r.zoom - t.zoom) > 0.001; } property int cropRevision: 0 readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null readonly property real maxZoom: 5.0 property var screens: [] property int selectedScreenIndex: 0 property bool shouldBeActive: true function applyCrop(): void { if (!cropRectLoader.item || !currentScreen) return; const cropRect = cropRectLoader.item; const cropXPercent = (cropRect.x - cropRect.imageX) / cropRect.imageW; const cropYPercent = (cropRect.y - cropRect.imageY) / cropRect.imageH; const cropWidthPercent = cropRect.width / cropRect.imageW; const cropHeightPercent = cropRect.height / cropRect.imageH; Wallpapers.setCrop(currentScreen.name, Qt.rect(cropXPercent, cropYPercent, cropWidthPercent, cropHeightPercent), cropRect.zoom); cropRevision++; } function refreshScreens(): void { screens = [...Quickshell.screens].sort((a, b) => a.x - b.x); selectedScreenIndex = 0; } function selectScreen(index: int): void { if (index < 0 || index >= screens.length || index === selectedScreenIndex) return; selectedScreenIndex = index; if (cropRectLoader.item) Qt.callLater(syncCropToScreen); } function sliderFromZoom(zoom: real): real { return (1 - 1 / zoom) / (1 - 1 / maxZoom); } function syncCropToScreen(): void { if (cropRectLoader.item) cropRectLoader.item.restoreFromData(); } function zoomClipRect(zoom: real): void { if (!cropRectLoader.item) return; const cropRect = cropRectLoader.item; const centerX = cropRect.x + cropRect.width * 0.5; const centerY = cropRect.y + cropRect.height * 0.5; cropRect.zoom = zoom; cropRect.x = centerX - cropRect.width * 0.5; cropRect.y = centerY - cropRect.height * 0.5; cropRect.clampToBounds(); } function zoomFromSlider(t: real): real { return 1 / (1 - t * (1 - 1 / maxZoom)); } anchors.left: parent.left anchors.right: parent.right implicitHeight: shouldBeActive ? 430 : 0 opacity: shouldBeActive ? 1 : 0 scale: shouldBeActive ? 1 : 0.8 visible: opacity > 0 Behavior on opacity { Anim { } } Behavior on scale { Anim { } } Behavior on y { Anim { } } Component.onCompleted: { refreshScreens(); Qt.callLater(syncCropToScreen); } onSelectedScreenIndexChanged: { Qt.callLater(syncCropToScreen); } ButtonRow { id: screenSelector anchors.bottom: imageSlot.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.leftMargin: Tokens.padding.extraLarge * 2 anchors.right: parent.right anchors.rightMargin: Tokens.padding.extraLarge * 2 anchors.top: parent.top spacing: Tokens.spacing.small Repeater { model: wrapper.screens delegate: TextButton { required property int index readonly property bool isCurrent: wrapper.selectedScreenIndex === index required property var modelData fillWidth: true font: Tokens.font.body.builders.large.weight(Font.DemiBold).build() inactiveColor: isCurrent ? Colors.palette.m3primary : Qt.alpha(Colors.palette.m3surfaceContainer, 0.7) inactiveOnColor: isCurrent ? Colors.palette.m3onPrimary : Qt.alpha(Colors.palette.m3onSurface, 0.7) isRound: true shapeMorph: true text: modelData.name onClicked: wrapper.selectScreen(index) } } } RowLayout { id: sliderLayout anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right implicitHeight: 30 CustomSlider { id: zoomSlider Layout.fillWidth: true Layout.leftMargin: Tokens.padding.small Layout.preferredHeight: Tokens.padding.medium * 3 Layout.rightMargin: Tokens.padding.small implicitHeight: Tokens.padding.small * 3 insetIcon: "crop" value: cropRectLoader.item ? wrapper.sliderFromZoom(cropRectLoader.item.zoom) : 0 onInteraction: value => { wrapper.zoomClipRect(wrapper.zoomFromSlider(value)); } } ButtonRow { spacing: Tokens.spacing.medium IconTextButton { enabled: wrapper.changesMade font: Tokens.font.body.large horizontalPadding: Tokens.padding.large icon: "close" inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveOnColor: Colors.palette.m3onSurfaceVariant isRound: true shapeMorph: true text: qsTr("Cancel") onClicked: { wrapper.syncCropToScreen(); } } IconTextButton { enabled: wrapper.changesMade font: Tokens.font.body.large horizontalPadding: Tokens.padding.large icon: "check" inactiveColor: Colors.palette.m3secondary inactiveOnColor: Colors.palette.m3onSecondary isRound: true shapeMorph: true text: qsTr("Apply") onClicked: { wrapper.applyCrop(); } } } } Item { id: imageSlot anchors.bottom: sliderLayout.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.topMargin: wrapper.buttonRowHeight + Tokens.spacing.medium CustomClippingRect { id: fitBox readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1 readonly property real fitW: imageSlot.height * aspect anchors.centerIn: parent implicitHeight: Math.round(fitW / aspect) implicitWidth: Math.round(fitW) radius: Tokens.rounding.largeIncreased onAspectChanged: console.log(aspect) Loader { active: opacity > 0 anchors.centerIn: parent opacity: scaledImg.status === Image.Ready ? 0 : 1 Behavior on opacity { Anim { type: Anim.DefaultEffects } } sourceComponent: CustomRect { color: Colors.palette.m3primaryContainer implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 radius: Tokens.rounding.full LoadingIndicator { id: loadingIndicator anchors.centerIn: parent containsIcon: true implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3 } } } Image { id: scaledImg anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectFit mipmap: true opacity: status === Image.Ready ? 1 : 0 retainWhileLoading: true smooth: true source: Wallpapers.current Behavior on opacity { Anim { type: Anim.SlowEffects } } } Loader { id: cropRectLoader active: scaledImg.status === Image.Ready sourceComponent: Component { CustomRect { id: cropRect property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 readonly property real baseHeight: baseWidth / aspectRatio readonly property real baseWidth: { let fittedHeight = imageH; let fittedWidth = fittedHeight * aspectRatio; if (fittedWidth > imageW) { fittedWidth = imageW; fittedHeight = fittedWidth / aspectRatio; } return fittedWidth; } readonly property real imageH: Math.round(fitBox.height) readonly property real imageW: Math.round(fitBox.width) readonly property real imageX: 0 readonly property real imageY: 0 property real imgAspectRatio: imageH > 0 ? imageW / imageH : 1 property real zoom: 1.0 function centerInImage() { x = Math.round(imageX + (imageW - width) / 2); y = Math.round(imageY + (imageH - height) / 2); } function clampToBounds() { x = Math.max(imageX, Math.min(x, imageX + imageW - width)); y = Math.max(imageY, Math.min(y, imageY + imageH - height)); } function restoreFromData() { const t = savedTarget(); zoom = t.zoom; x = t.x; y = t.y; } // Where the rect should be according to saved data (same rules as a restore). function savedTarget() { const data = Wallpapers.getCrop(wrapper.currentScreen.name); const hasCrop = data && (Math.abs(data.x) > 0.001 || Math.abs(data.y) > 0.001 || Math.abs(data.width - 1.0) > 0.001 || Math.abs(data.height - 1.0) > 0.001); const z = hasCrop && data.zoom > 0 ? data.zoom : 1.0; const w = Math.round(baseWidth / z); const h = Math.round(baseHeight / z); let tx = hasCrop ? Math.round(imageX + data.x * imageW) : Math.round(imageX + (imageW - w) / 2); let ty = hasCrop ? Math.round(imageY + data.y * imageH) : Math.round(imageY + (imageH - h) / 2); tx = Math.max(imageX, Math.min(tx, imageX + imageW - w)); ty = Math.max(imageY, Math.min(ty, imageY + imageH - h)); return { x: tx, y: ty, zoom: z }; } color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent" height: Math.round(baseHeight / zoom) width: Math.round(baseWidth / zoom) Behavior on color { CAnim { } } Component.onCompleted: { restoreFromData(); } onHeightChanged: clampToBounds() onWidthChanged: clampToBounds() } } } ShaderEffect { property color borderColor: Colors.palette.m3tertiary property real borderWidth: 2 property real cornerRadius: fitBox.radius property vector4d cropBox: cropRectLoader.item ? Qt.vector4d(snap(cropRectLoader.item.x), snap(cropRectLoader.item.y), snap(cropRectLoader.item.width), snap(cropRectLoader.item.height)) : Qt.vector4d(0, 0, 0, 0) readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1 property vector2d itemSize: Qt.vector2d(width, height) property real pixelScale: dpr function snap(v: real): real { return Math.round(v * dpr) / dpr; } anchors.fill: parent fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/crop_sdf.frag.qsb`) } MouseArea { id: mouse property real grabX: 0 property real grabY: 0 function beginGrab(mx, my): void { const r = cropRectLoader.item; if (!r) return; const inside = insideCropRect(mx, my); if (inside) { grabX = mx - r.x; grabY = my - r.y; } else { grabX = r.width * 0.5; grabY = r.height * 0.5; } } function insideCropRect(mx, my): bool { const r = cropRectLoader.item; if (!r) return false; return mx >= r.x && mx <= r.x + r.width && my >= r.y && my <= r.y + r.height; } function updateCrop(mx, my): void { if (!cropRectLoader.item) return; const cropRect = cropRectLoader.item; let nx = mx - grabX; let ny = my - grabY; nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + cropRect.imageW - cropRect.width)); ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + cropRect.imageH - cropRect.height)); cropRect.x = nx; cropRect.y = ny; } anchors.fill: parent cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor hoverEnabled: true preventStealing: true onPositionChanged: mouse => { if (pressed) { updateCrop(mouse.x, mouse.y); } } onPressed: mouse => { beginGrab(mouse.x, mouse.y); updateCrop(mouse.x, mouse.y); } } } } }