Files
z-bar-qt/Modules/Settings/Common/WallpaperCropper.qml
T

438 lines
11 KiB
QML

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
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
readonly property real maxZoom: 5.0
property int buttonRowHeight: Tokens.padding.large * 3
property bool changesMade: false
property var screens: []
property int selectedScreenIndex: 0
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 {
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);
}
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 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();
}
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
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
font: Tokens.font.body.builders.large.weight(Font.DemiBold).build()
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));
}
onPosChanged: wrapper.changesMade = true
}
ButtonRow {
spacing: Tokens.spacing.medium
IconTextButton {
isRound: true
icon: "close"
text: qsTr("Cancel")
font: Tokens.font.body.large
shapeMorph: true
horizontalPadding: Tokens.padding.large
inactiveColor: Colors.layer(Colors.palette.m3surfaceContainerHighest, 2)
inactiveOnColor: Colors.palette.m3onSurfaceVariant
enabled: wrapper.changesMade
onClicked: {
wrapper.syncCropToScreen();
wrapper.changesMade = false;
}
}
IconTextButton {
isRound: true
icon: "check"
text: qsTr("Apply")
font: Tokens.font.body.large
shapeMorph: true
horizontalPadding: Tokens.padding.large
inactiveColor: Colors.palette.m3secondary
inactiveOnColor: Colors.palette.m3onSecondary
enabled: wrapper.changesMade
onClicked: {
wrapper.applyCrop();
wrapper.changesMade = false;
}
}
}
}
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: Math.min(imageSlot.width, imageSlot.height * aspect)
implicitHeight: Math.round(fitW / aspect)
implicitWidth: Math.round(fitW)
x: Math.round((imageSlot.width - width) / 2)
y: Math.round((imageSlot.height - height) / 2)
radius: Tokens.rounding.largeIncreased
Loader {
anchors.centerIn: parent
opacity: scaledImg.status === Image.Ready ? 0 : 1
active: opacity > 0
sourceComponent: CustomRect {
implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2
implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2
color: Colors.palette.m3primaryContainer
radius: Tokens.rounding.full
LoadingIndicator {
id: loadingIndicator
anchors.centerIn: parent
containsIcon: true
implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3
}
}
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Image {
id: scaledImg
anchors.fill: parent
asynchronous: true
fillMode: Image.Stretch
source: Wallpapers.current
sourceSize: {
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
return Qt.size(width * dpr, height * dpr);
}
retainWhileLoading: true
opacity: status === Image.Ready ? 1 : 0
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() {
let data = Wallpapers.getCrop(wrapper.currentScreen.name);
if (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)) {
zoom = data.zoom > 0 ? data.zoom : 1.0;
x = Math.round(imageX + (data.x * imageW));
y = Math.round(imageY + (data.y * imageH));
clampToBounds();
} else {
zoom = 1.0;
centerInImage();
}
}
height: Math.round(baseHeight / zoom)
width: Math.round(baseWidth / zoom)
color: zoomSlider.dragging || mouse.pressed ? Qt.alpha(Colors.palette.m3tertiary, 0.3) : "transparent"
Behavior on color {
CAnim {}
}
Component.onCompleted: {
restoreFromData();
}
onHeightChanged: clampToBounds()
onWidthChanged: clampToBounds()
}
}
}
ShaderEffect {
anchors.fill: parent
readonly property real dpr: (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1
function snap(v: real): real {
return Math.round(v * dpr) / dpr;
}
property color borderColor: Colors.palette.m3tertiary
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`)
}
MouseArea {
id: mouse
property real grabX: 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 {
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 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
hoverEnabled: true
preventStealing: true
cursorShape: pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
onPositionChanged: mouse => {
if (pressed) {
updateCrop(mouse.x, mouse.y);
wrapper.changesMade = true;
}
}
onPressed: mouse => {
beginGrab(mouse.x, mouse.y);
updateCrop(mouse.x, mouse.y);
}
}
}
}
}