fix: sizing and positioning of portrait aspect ratios for wallpaper cropper

This commit is contained in:
2026-09-30 23:33:22 +02:00
parent 8dd65a90a8
commit ad36b4ed8f
+72 -80
View File
@@ -13,9 +13,7 @@ import qs.Services
Item { Item {
id: wrapper id: wrapper
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null property int buttonRowHeight: Tokens.padding.large * 3
readonly property real maxZoom: 5.0
readonly property bool changesMade: { readonly property bool changesMade: {
cropRevision; cropRevision;
@@ -27,21 +25,13 @@ Item {
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; 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 property int cropRevision: 0
property int buttonRowHeight: Tokens.padding.large * 3 readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
readonly property real maxZoom: 5.0
property var screens: [] property var screens: []
property int selectedScreenIndex: 0 property int selectedScreenIndex: 0
property bool shouldBeActive: true property bool shouldBeActive: true
function zoomFromSlider(t: real): real {
return 1 / (1 - t * (1 - 1 / maxZoom));
}
function sliderFromZoom(zoom: real): real {
return (1 - 1 / zoom) / (1 - 1 / maxZoom);
}
function applyCrop(): void { function applyCrop(): void {
if (!cropRectLoader.item || !currentScreen) if (!cropRectLoader.item || !currentScreen)
return; return;
@@ -72,6 +62,10 @@ Item {
Qt.callLater(syncCropToScreen); Qt.callLater(syncCropToScreen);
} }
function sliderFromZoom(zoom: real): real {
return (1 - 1 / zoom) / (1 - 1 / maxZoom);
}
function syncCropToScreen(): void { function syncCropToScreen(): void {
if (cropRectLoader.item) if (cropRectLoader.item)
cropRectLoader.item.restoreFromData(); cropRectLoader.item.restoreFromData();
@@ -94,6 +88,10 @@ Item {
cropRect.clampToBounds(); cropRect.clampToBounds();
} }
function zoomFromSlider(t: real): real {
return 1 / (1 - t * (1 - 1 / maxZoom));
}
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
implicitHeight: shouldBeActive ? 430 : 0 implicitHeight: shouldBeActive ? 430 : 0
@@ -102,13 +100,16 @@ Item {
visible: opacity > 0 visible: opacity > 0
Behavior on opacity { Behavior on opacity {
Anim {} Anim {
}
} }
Behavior on scale { Behavior on scale {
Anim {} Anim {
}
} }
Behavior on y { Behavior on y {
Anim {} Anim {
}
} }
Component.onCompleted: { Component.onCompleted: {
@@ -140,12 +141,12 @@ Item {
required property var modelData required property var modelData
fillWidth: true fillWidth: true
font: Tokens.font.body.builders.large.weight(Font.DemiBold).build()
inactiveColor: isCurrent ? Colors.palette.m3primary : Qt.alpha(Colors.palette.m3surfaceContainer, 0.7) inactiveColor: isCurrent ? Colors.palette.m3primary : Qt.alpha(Colors.palette.m3surfaceContainer, 0.7)
inactiveOnColor: isCurrent ? Colors.palette.m3onPrimary : Qt.alpha(Colors.palette.m3onSurface, 0.7) inactiveOnColor: isCurrent ? Colors.palette.m3onPrimary : Qt.alpha(Colors.palette.m3onSurface, 0.7)
isRound: true isRound: true
shapeMorph: true shapeMorph: true
text: modelData.name text: modelData.name
font: Tokens.font.body.builders.large.weight(Font.DemiBold).build()
onClicked: wrapper.selectScreen(index) onClicked: wrapper.selectScreen(index)
} }
@@ -180,15 +181,15 @@ Item {
spacing: Tokens.spacing.medium spacing: Tokens.spacing.medium
IconTextButton { IconTextButton {
isRound: true enabled: wrapper.changesMade
icon: "close"
text: qsTr("Cancel")
font: Tokens.font.body.large font: Tokens.font.body.large
shapeMorph: true
horizontalPadding: Tokens.padding.large horizontalPadding: Tokens.padding.large
icon: "close"
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2) inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: Colors.palette.m3onSurfaceVariant inactiveOnColor: Colors.palette.m3onSurfaceVariant
enabled: wrapper.changesMade isRound: true
shapeMorph: true
text: qsTr("Cancel")
onClicked: { onClicked: {
wrapper.syncCropToScreen(); wrapper.syncCropToScreen();
@@ -196,15 +197,15 @@ Item {
} }
IconTextButton { IconTextButton {
isRound: true enabled: wrapper.changesMade
icon: "check"
text: qsTr("Apply")
font: Tokens.font.body.large font: Tokens.font.body.large
shapeMorph: true
horizontalPadding: Tokens.padding.large horizontalPadding: Tokens.padding.large
icon: "check"
inactiveColor: Colors.palette.m3secondary inactiveColor: Colors.palette.m3secondary
inactiveOnColor: Colors.palette.m3onSecondary inactiveOnColor: Colors.palette.m3onSecondary
enabled: wrapper.changesMade isRound: true
shapeMorph: true
text: qsTr("Apply")
onClicked: { onClicked: {
wrapper.applyCrop(); wrapper.applyCrop();
@@ -227,42 +228,39 @@ Item {
id: fitBox id: fitBox
readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1 readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1
readonly property real fitW: Math.min(imageSlot.width, imageSlot.height * aspect) readonly property real fitW: imageSlot.height * aspect
anchors.centerIn: parent
implicitHeight: Math.round(fitW / aspect) implicitHeight: Math.round(fitW / aspect)
implicitWidth: Math.round(fitW) implicitWidth: Math.round(fitW)
x: Math.round((imageSlot.width - width) / 2)
y: Math.round((imageSlot.height - height) / 2)
radius: Tokens.rounding.largeIncreased radius: Tokens.rounding.largeIncreased
onAspectChanged: console.log(aspect)
Loader { Loader {
anchors.centerIn: parent
opacity: scaledImg.status === Image.Ready ? 0 : 1
active: opacity > 0 active: opacity > 0
anchors.centerIn: parent
opacity: scaledImg.status === Image.Ready ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
sourceComponent: CustomRect { sourceComponent: CustomRect {
implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2
implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2
color: Colors.palette.m3primaryContainer color: Colors.palette.m3primaryContainer
implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2
implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2
radius: Tokens.rounding.full radius: Tokens.rounding.full
LoadingIndicator { LoadingIndicator {
id: loadingIndicator id: loadingIndicator
anchors.centerIn: parent anchors.centerIn: parent
containsIcon: true containsIcon: true
implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3 implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3
} }
} }
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
} }
Image { Image {
@@ -270,14 +268,12 @@ Item {
anchors.fill: parent anchors.fill: parent
asynchronous: true asynchronous: true
fillMode: Image.Stretch fillMode: Image.PreserveAspectFit
source: Wallpapers.current mipmap: true
sourceSize: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return Qt.size(width * dpr, height * dpr);
}
retainWhileLoading: true
opacity: status === Image.Ready ? 1 : 0 opacity: status === Image.Ready ? 1 : 0
retainWhileLoading: true
smooth: true
source: Wallpapers.current
Behavior on opacity { Behavior on opacity {
Anim { Anim {
@@ -297,7 +293,6 @@ Item {
property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1
readonly property real baseHeight: baseWidth / aspectRatio readonly property real baseHeight: baseWidth / aspectRatio
readonly property real baseWidth: { readonly property real baseWidth: {
let fittedHeight = imageH; let fittedHeight = imageH;
let fittedWidth = fittedHeight * aspectRatio; let fittedWidth = fittedHeight * aspectRatio;
@@ -309,7 +304,6 @@ Item {
return fittedWidth; return fittedWidth;
} }
readonly property real imageH: Math.round(fitBox.height) readonly property real imageH: Math.round(fitBox.height)
readonly property real imageW: Math.round(fitBox.width) readonly property real imageW: Math.round(fitBox.width)
readonly property real imageX: 0 readonly property real imageX: 0
@@ -327,6 +321,13 @@ Item {
y = Math.max(imageY, Math.min(y, imageY + imageH - height)); 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). // Where the rect should be according to saved data (same rules as a restore).
function savedTarget() { function savedTarget() {
const data = Wallpapers.getCrop(wrapper.currentScreen.name); const data = Wallpapers.getCrop(wrapper.currentScreen.name);
@@ -349,19 +350,13 @@ Item {
}; };
} }
function restoreFromData() { color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent"
const t = savedTarget();
zoom = t.zoom;
x = t.x;
y = t.y;
}
height: Math.round(baseHeight / zoom) height: Math.round(baseHeight / zoom)
width: Math.round(baseWidth / zoom) width: Math.round(baseWidth / zoom)
color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent"
Behavior on color { Behavior on color {
CAnim {} CAnim {
}
} }
Component.onCompleted: { Component.onCompleted: {
@@ -374,21 +369,19 @@ Item {
} }
ShaderEffect { ShaderEffect {
anchors.fill: parent 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 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 { function snap(v: real): real {
return Math.round(v * dpr) / dpr; return Math.round(v * dpr) / dpr;
} }
property color borderColor: Colors.palette.m3tertiary anchors.fill: parent
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)
property real borderWidth: 2
property real cornerRadius: fitBox.radius
property vector2d itemSize: Qt.vector2d(width, height)
property real pixelScale: dpr
fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/crop_sdf.frag.qsb`) fragmentShader: Qt.resolvedUrl(`${Quickshell.shellDir}/assets/shaders/crop_sdf.frag.qsb`)
} }
@@ -398,14 +391,6 @@ Item {
property real grabX: 0 property real grabX: 0
property real grabY: 0 property real grabY: 0
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 beginGrab(mx, my): void { function beginGrab(mx, my): void {
const r = cropRectLoader.item; const r = cropRectLoader.item;
if (!r) if (!r)
@@ -422,6 +407,14 @@ Item {
} }
} }
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 { function updateCrop(mx, my): void {
if (!cropRectLoader.item) if (!cropRectLoader.item)
return; return;
@@ -439,11 +432,10 @@ Item {
} }
anchors.fill: parent anchors.fill: parent
cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
hoverEnabled: true hoverEnabled: true
preventStealing: true preventStealing: true
cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
onPositionChanged: mouse => { onPositionChanged: mouse => {
if (pressed) { if (pressed) {
updateCrop(mouse.x, mouse.y); updateCrop(mouse.x, mouse.y);