484 lines
13 KiB
QML
484 lines
13 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 bool isVideo: Wallpapers.currentIsVideo
|
|
readonly property var currentScreen: screens.length > selectedScreenIndex ? screens[selectedScreenIndex] : null
|
|
readonly property real maxZoom: 5.0
|
|
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 buttonRowHeight: Tokens.padding.large * 3
|
|
property int cropRevision: 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);
|
|
}
|
|
|
|
Loader {
|
|
id: selectorLoader
|
|
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
|
|
|
|
active: wrapper.screens.length > 1 && !wrapper.isVideo
|
|
|
|
sourceComponent: ButtonRow {
|
|
id: screenSelector
|
|
|
|
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
|
|
enabled: !wrapper.isVideo
|
|
|
|
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: selectorLoader.active ? wrapper.buttonRowHeight + Tokens.spacing.medium : Tokens.spacing.medium
|
|
|
|
ClipRect {
|
|
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
|
|
|
|
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.currentThumbnail
|
|
sourceSize.height: {
|
|
const dpr = (QsWindow.window as QsWindow)?.devicePixelRatio ?? 1;
|
|
return imageSlot.height * dpr;
|
|
}
|
|
|
|
Behavior on opacity {
|
|
Anim {
|
|
type: Anim.SlowEffects
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
opacity: wrapper.isVideo && scaledImg.status === Image.Ready ? 1 : 0
|
|
active: opacity > 0
|
|
anchors.centerIn: parent
|
|
Behavior on opacity {
|
|
Anim {
|
|
type: Anim.SlowEffects
|
|
}
|
|
}
|
|
|
|
sourceComponent: CustomRect {
|
|
implicitWidth: videoIcon.implicitWidth + Tokens.padding.extraLarge * 2
|
|
implicitHeight: implicitWidth
|
|
radius: Tokens.rounding.full
|
|
color: Colors.tPalette.m3surface
|
|
|
|
MaterialIcon {
|
|
id: videoIcon
|
|
anchors.centerIn: parent
|
|
text: "video_library"
|
|
fontStyle: Tokens.font.icon.extraLarge
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
id: cropRectLoader
|
|
|
|
active: scaledImg.status === Image.Ready && !wrapper.isVideo
|
|
|
|
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;
|
|
}
|
|
|
|
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: wrapper.isVideo ? Qt.ArrowCursor : pressed ? Qt.ClosedHandCursor : insideCropRect(mouseX, mouseY) ? Qt.OpenHandCursor : Qt.PointingHandCursor
|
|
hoverEnabled: true
|
|
preventStealing: true
|
|
enabled: !wrapper.isVideo
|
|
|
|
onPositionChanged: mouse => {
|
|
if (pressed) {
|
|
updateCrop(mouse.x, mouse.y);
|
|
}
|
|
}
|
|
onPressed: mouse => {
|
|
beginGrab(mouse.x, mouse.y);
|
|
updateCrop(mouse.x, mouse.y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|