From f41075d316c3de89b50464a72896bc21b9fc021d Mon Sep 17 00:00:00 2001 From: zach Date: Wed, 30 Sep 2026 00:41:01 +0200 Subject: [PATCH] feat: wallpaper cropper sdf shader rectangle, better confirmation controls + hide passive tray icons --- Modules/Bar/Components/TrayIcons.qml | 2 +- Modules/Settings/Common/WallpaperCropper.qml | 404 +++++++++++-------- assets/shaders/crop_sdf.frag | 40 ++ assets/shaders/crop_sdf.frag.qsb | Bin 0 -> 1999 bytes shell.qml | 20 +- 5 files changed, 294 insertions(+), 172 deletions(-) create mode 100644 assets/shaders/crop_sdf.frag create mode 100644 assets/shaders/crop_sdf.frag.qsb diff --git a/Modules/Bar/Components/TrayIcons.qml b/Modules/Bar/Components/TrayIcons.qml index 6fe8ac7..fae91a7 100644 --- a/Modules/Bar/Components/TrayIcons.qml +++ b/Modules/Bar/Components/TrayIcons.qml @@ -42,7 +42,7 @@ WidgetBase { Repeater { id: repeater - model: SystemTray.items + model: SystemTray.items.values.filter(i => i.status !== Status.Passive && i.hasMenu) TrayItem { id: trayItem diff --git a/Modules/Settings/Common/WallpaperCropper.qml b/Modules/Settings/Common/WallpaperCropper.qml index b2fea36..68076b0 100644 --- a/Modules/Settings/Common/WallpaperCropper.qml +++ b/Modules/Settings/Common/WallpaperCropper.qml @@ -26,10 +26,10 @@ Item { const cropRect = cropRectLoader.item; - const cropXPercent = (cropRect.x - cropRect.imageX) / scaledImg.paintedWidth; - const cropYPercent = (cropRect.y - cropRect.imageY) / scaledImg.paintedHeight; - const cropWidthPercent = cropRect.width / scaledImg.paintedWidth; - const cropHeightPercent = cropRect.height / scaledImg.paintedHeight; + 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); } @@ -79,16 +79,13 @@ Item { visible: opacity > 0 Behavior on opacity { - Anim { - } + Anim {} } Behavior on scale { - Anim { - } + Anim {} } Behavior on y { - Anim { - } + Anim {} } Component.onCompleted: { @@ -99,34 +96,10 @@ Item { Qt.callLater(syncCropToScreen); } - IconButton { - anchors.margins: Tokens.padding.small - anchors.right: parent.right - anchors.top: parent.top - icon: "check" - opacity: wrapper.changesMade ? 1 : 0 - scale: wrapper.changesMade ? 1 : 0 - z: 2 - - Behavior on opacity { - Anim { - } - } - Behavior on scale { - Anim { - } - } - - onClicked: { - wrapper.applyCrop(); - wrapper.changesMade = false; - } - } - ButtonRow { id: screenSelector - anchors.bottom: scaledImg.top + anchors.bottom: imageSlot.top anchors.bottomMargin: Tokens.spacing.medium anchors.left: parent.left anchors.leftMargin: Tokens.padding.extraLarge * 2 @@ -149,6 +122,7 @@ Item { isRound: true shapeMorph: true text: modelData.name + font: Tokens.font.body.builders.large.weight(Font.DemiBold).build() onClicked: wrapper.selectScreen(index) } @@ -178,16 +152,52 @@ Item { onInteraction: value => { wrapper.zoomClipRect(1 + (value * 4)); - wrapper.changesMade = true; + } + + 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; + } } } } - Image { - id: scaledImg - - property var displayData - property real monitorScale: 1.0 + Item { + id: imageSlot anchors.bottom: sliderLayout.top anchors.bottomMargin: Tokens.spacing.medium @@ -195,147 +205,225 @@ Item { anchors.right: parent.right anchors.top: parent.top anchors.topMargin: wrapper.buttonRowHeight + Tokens.spacing.medium - // anchors.top: screenSelector.bottom - asynchronous: true - fillMode: Image.PreserveAspectFit - retainWhileLoading: true - source: Wallpapers.current - sourceSize.height: parent.height - sourceSize.width: parent.width - onPaintedWidthChanged: { - if (paintedWidth > 0 && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + CustomClippingRect { + id: fitBox - wrapper.syncCropToScreen(); - } - } - onSourceChanged: { - if (cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + readonly property real aspect: scaledImg.implicitHeight > 0 ? scaledImg.implicitWidth / scaledImg.implicitHeight : 1 + readonly property real fitW: Math.min(imageSlot.width, imageSlot.height * aspect) - wrapper.syncCropToScreen(); - } - } - onStatusChanged: { - if (scaledImg.status == Image.Ready && cropRectLoader.item) { - if (!wrapper.screens.length) - wrapper.refreshScreens(); + 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 - wrapper.syncCropToScreen(); - } - } + Loader { + anchors.centerIn: parent - Loader { - id: cropRectLoader + opacity: scaledImg.status === Image.Ready ? 0 : 1 + active: opacity > 0 - active: scaledImg.status === Image.Ready + sourceComponent: CustomRect { + implicitWidth: loadingIndicator.implicitSize + Tokens.padding.large * 2 + implicitHeight: loadingIndicator.implicitSize + Tokens.padding.large * 2 - sourceComponent: Component { - CustomRect { - id: cropRect + color: Colors.palette.m3primaryContainer + radius: Tokens.rounding.full - property real aspectRatio: wrapper.currentScreen ? wrapper.currentScreen.width / wrapper.currentScreen.height : 1 - readonly property real baseHeight: baseWidth / aspectRatio - readonly property real baseWidth: { - let fittedHeight = scaledImg.paintedHeight; - let fittedWidth = fittedHeight * aspectRatio; + LoadingIndicator { + id: loadingIndicator - if (fittedWidth > scaledImg.paintedWidth) { - fittedWidth = scaledImg.paintedWidth; - fittedHeight = fittedWidth / aspectRatio; - } + anchors.centerIn: parent - return fittedWidth; + containsIcon: true + implicitSize: Math.min(fitBox.width, fitBox.height) * 0.3 } - readonly property real imageX: (scaledImg.width - scaledImg.paintedWidth) / 2 - readonly property real imageY: (scaledImg.height - scaledImg.paintedHeight) / 2 - property real imgAspectRatio: scaledImg.paintedWidth / scaledImg.paintedHeight - property real zoom: 1.0 + } - function centerInImage() { - x = imageX + (scaledImg.paintedWidth - width) / 2; - y = imageY + (scaledImg.paintedHeight - height) / 2; + Behavior on opacity { + Anim { + type: Anim.DefaultEffects } - - function clampToBounds() { - x = Math.max(imageX, Math.min(x, imageX + scaledImg.paintedWidth - width)); - y = Math.max(imageY, Math.min(y, imageY + scaledImg.paintedHeight - 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 = imageX + (data.x * scaledImg.paintedWidth); - y = imageY + (data.y * scaledImg.paintedHeight); - - clampToBounds(); - } else { - zoom = 1.0; - centerInImage(); - } - } - - border.color: Colors.palette.m3primary - border.width: 2 - height: baseHeight / zoom - opacity: 1 - width: baseWidth / zoom - - Behavior on opacity { - Anim { - } - } - - Component.onCompleted: { - restoreFromData(); - } - onHeightChanged: clampToBounds() - onWidthChanged: clampToBounds() } } - } - MouseArea { - id: mouse + Image { + id: scaledImg - function updateCrop(mouseX, mouseY) { - if (!cropRectLoader.item) - return; + 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 - const cropRect = cropRectLoader.item; - - let nx = mouseX - cropRect.width * 0.5; - let ny = mouseY - cropRect.height * 0.5; - - nx = Math.max(cropRect.imageX, Math.min(nx, cropRect.imageX + scaledImg.paintedWidth - cropRect.width)); - ny = Math.max(cropRect.imageY, Math.min(ny, cropRect.imageY + scaledImg.paintedHeight - cropRect.height)); - - cropRect.x = nx; - cropRect.y = ny; + Behavior on opacity { + Anim { + type: Anim.SlowEffects + } + } } - anchors.fill: parent - hoverEnabled: true - preventStealing: true + Loader { + id: cropRectLoader - onPositionChanged: mouse => { - if (pressed) { + 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); - wrapper.changesMade = true; } } - onPressed: mouse => { - updateCrop(mouse.x, mouse.y); - wrapper.changesMade = true; - } - onReleased: { - wrapper.changesMade = true; - } } } } diff --git a/assets/shaders/crop_sdf.frag b/assets/shaders/crop_sdf.frag new file mode 100644 index 0000000..bbe2aa0 --- /dev/null +++ b/assets/shaders/crop_sdf.frag @@ -0,0 +1,40 @@ +#version 440 +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + vec2 itemSize; + vec4 cropBox; + vec4 borderColor; + float cornerRadius; + float borderWidth; + float pixelScale; +}; + +float sdRoundBox(vec2 p, vec2 halfSize, float r) { + vec2 q = abs(p) - halfSize + r; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +void main() { + vec2 sz = itemSize * pixelScale; + vec2 p = qt_TexCoord0 * sz; + vec4 box = cropBox * pixelScale; + float r = cornerRadius * pixelScale; + float bw = borderWidth * pixelScale; + + float dImage = sdRoundBox(p - sz * 0.5, sz * 0.5, r); + float dCrop = sdRoundBox(p - (box.xy + box.zw * 0.5), box.zw * 0.5, 0.0); + + float d = max(dImage, dCrop); + + // ramp is exactly 1 physical pixel wide + float outer = clamp(0.5 - d, 0.0, 1.0); + float inner = clamp(0.5 - (d + bw), 0.0, 1.0); + float a = outer - inner; + + fragColor = vec4(borderColor.rgb, 1.0) * (borderColor.a * a * qt_Opacity); +} diff --git a/assets/shaders/crop_sdf.frag.qsb b/assets/shaders/crop_sdf.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..786ccc013b2e6be6fb8eca6da93f90d1f49cebc7 GIT binary patch literal 1999 zcmV;=2Qc^m03UyNob6X@R~y$A-5`OeOdKbE1hLX|N%PE^Gh(%4DtVuKr} zwNrT{4KR(Q5u*`Eq)we@>%5vaZQlKZ{RREf$2xcR6_)|hFHKk13bWQM?%8Lb$35rX zGXlVO03-nn02sp>LX>d>F1nb94<7+)C;%7)fPeA9E{`~P7|RIYU_~3(wRz)?yzriz75yKno74@DQTMkh3PY_;X0p zTr{u*51Vkg|I5r{$8!)>1n|+q9DJRW_`PN)Vn4@FVP#zeJ4$tDFS9XJHLa=57U7@< z4;{3*pVnUc*&gpEJk$_km1f~yW=GJ{C3aEA5~^@e*QF36v&aAHV;t7Dpa*Uz22Z9W z<^hc0uS6fhpw8j|hV|b|SC+2i+hHv~JtJoo2a(V`(LIQib}Vnh@tT5{0NmGQPG~$O zK9E4Z-{_=X%QK|qp}w4Adu;3^{~Bz7*sO&O61!|+31ZhQEJ^H+g$)sFS=cbK(85y0 zq&t_8)E9T0xf%H}OO`sEu;lxed>Df|=W!%-p0YlMgpR8KJc6W-H%y&EQeTb~Uy)_F z7|u9`k>+}YujnU`)Yx&xJ&GhAWeiaNsFjzI$605|BS*hdyD=m&3202@6G-dYOp=qL z&e)h&^8GRQBK#s{GyEj4Gt6s%d5An^?f)#-DNBFOs>c)5n|ME=uN32nTp}j=i&nnI z-#pKa=%1lKTmJ&ji;))@&(!%=RK^QNj)v^L3*zth^qpfqBHt%w{JqP%O5NY1?}XKt zk7NALxSw+v(z%*TE}Xw(Q0FSKz$x@3sOO~_qd`!L`lV6JYyAzY07{3ye z-57r@CI>NoJ0^ExaxW&o7L(tid=W$XEKT$L$r&s0zReI9j-03S6=| zo}!)LPa~o0{FLSMX`VYHUtllZru{5A7ieE3COOC%{3&yorw=(l&k{FzUfLzk%fuy5 zv3ZVmml=xtRp`EN9>P^m7 z-XqafC_jmm*2#zXAn%RXRmgjZIun1{+K2RUg?DuUDXm{6Ca?J}8?G1Q{+gJ)gPap) zU$0Ov`+A*No&I03YSds(bt_IY7RM*n+$BzndQ-nO`jmA*y{y~(Zkc+8te5bG*Cx-U z&eVLJv&qOCoHe8L(`Ah^cM${0`@$@&7w=-l6?l#LVn@ zpZD-xtJfb8U%-g|Mtw-^1MB=r&#yDaAIO1)eMD@)!v081Y_B1uWBrNtA5ky+yog~v zBmQjV_OZ3czffo7PdNL{-1wB7Ppo)k-$>ICd z&5=Ol4+=O4FXE9#Bz6m_S_J8s>{A2PR7mhT_2Ugi#E z$P@gZ7}T=#UfRL`i3T{*H3%gCc5>Yf+MeH3X9|UMD{!lxmR7x$)s|YW`%YNg0ej7` zm`=CDpi>Q1rL&xVHLa9V4JVvdYhn4S69!(ls4?w9z=f7m^}=3+uDjJK<%Moz$=h`M z(6p)serwL}T1dqYYHl#^*ZshDSoMRZ8!S3CuhX{ihe%QkY16=YeC$dxLoj8kc6T3I#O=VsM}R%e22lxE(rzQis`%Q^t$iWl=L~XErs@`D#)gI1R3&XYA^|c0-4O&=@{MvM#z-g$GYN<(;X>Y32s*s<_ z#tG#!s-%o|+qux%DOHjRZo#$Z8qSIUWm9%$IHO9^>uovC2Yzdp6w7B#ZqsIA3PM9wQgV!5tS&mhE7&rkL^=%Ah$IIherET@NA)=-1fJ45PHqs zW?)wj4Ms%xr&H?hniTVfl>@%l?h~=I7+BH7Ccn=H%43 hd`|8<5%-7Z{x8k_Uz-2#zciQsH^a04{2PY{g