From b6379da96c3b6388830c6aee459373eb478e7e77 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Fri, 22 Aug 2025 13:59:49 -0400 Subject: [PATCH] Introducing fragment shaders for image rounding. --- Modules/Bar/MediaMini.qml | 8 +-- Modules/LockScreen/LockScreen.qml | 3 +- Modules/SettingsPanel/Tabs/AboutTab.qml | 5 +- Modules/SettingsPanel/Tabs/GeneralTab.qml | 4 +- Modules/SidePanel/Cards/MediaCard.qml | 3 +- Modules/SidePanel/Cards/ProfileCard.qml | 2 +- Shaders/frag/circled_image.frag | 30 +++++++++ Shaders/frag/rounded_image.frag | 56 +++++++++++++++++ Shaders/qsb/circled_image.frag.qsb | Bin 0 -> 1717 bytes Shaders/qsb/rounded_image.frag.qsb | Bin 0 -> 2767 bytes Widgets/NImageCircled.qml | 73 ++++++++++++++++++++++ Widgets/NImageRounded.qml | 40 +++++++----- 12 files changed, 193 insertions(+), 31 deletions(-) create mode 100644 Shaders/frag/circled_image.frag create mode 100644 Shaders/frag/rounded_image.frag create mode 100644 Shaders/qsb/circled_image.frag.qsb create mode 100644 Shaders/qsb/rounded_image.frag.qsb create mode 100644 Widgets/NImageCircled.qml diff --git a/Modules/Bar/MediaMini.qml b/Modules/Bar/MediaMini.qml index a061031..be07e87 100644 --- a/Modules/Bar/MediaMini.qml +++ b/Modules/Bar/MediaMini.qml @@ -109,14 +109,14 @@ Row { visible: Settings.data.audio.showMiniplayerAlbumArt Rectangle { - width: 16 * scaling - height: 16 * scaling + width: 18 * scaling + height: 18 * scaling radius: width * 0.5 color: Color.transparent antialiasing: true clip: true - NImageRounded { + NImageCircled { id: trackArt visible: MediaService.trackArtUrl.toString() !== "" anchors.fill: parent @@ -126,8 +126,6 @@ Row { fallbackIcon: MediaService.isPlaying ? "pause" : "play_arrow" borderWidth: 0 border.color: Color.transparent - imageRadius: width - antialiasing: true } // Fallback icon when no album art available diff --git a/Modules/LockScreen/LockScreen.qml b/Modules/LockScreen/LockScreen.qml index b980fa3..5f41bd7 100644 --- a/Modules/LockScreen/LockScreen.qml +++ b/Modules/LockScreen/LockScreen.qml @@ -465,13 +465,12 @@ Loader { } } - NImageRounded { + NImageCircled { anchors.centerIn: parent width: 100 * scaling height: 100 * scaling imagePath: Settings.data.general.avatarImage fallbackIcon: "person" - imageRadius: width * 0.5 } // Hover animation diff --git a/Modules/SettingsPanel/Tabs/AboutTab.qml b/Modules/SettingsPanel/Tabs/AboutTab.qml index a7b7b3d..b76c3d9 100644 --- a/Modules/SettingsPanel/Tabs/AboutTab.qml +++ b/Modules/SettingsPanel/Tabs/AboutTab.qml @@ -211,14 +211,13 @@ ColumnLayout { Layout.preferredWidth: Style.baseWidgetSize * 2 * scaling Layout.preferredHeight: Style.baseWidgetSize * 2 * scaling - NImageRounded { + NImageCircled { imagePath: modelData.avatar_url || "" anchors.fill: parent anchors.margins: Style.marginXS * scaling fallbackIcon: "person" borderColor: Color.mPrimary - borderWidth: Math.max(1, Style.borderL * scaling) - imageRadius: width * 0.5 + borderWidth: Math.max(1, Style.borderM * scaling) } } diff --git a/Modules/SettingsPanel/Tabs/GeneralTab.qml b/Modules/SettingsPanel/Tabs/GeneralTab.qml index 19da93f..f05cfa3 100644 --- a/Modules/SettingsPanel/Tabs/GeneralTab.qml +++ b/Modules/SettingsPanel/Tabs/GeneralTab.qml @@ -44,13 +44,13 @@ ColumnLayout { spacing: Style.marginL * scaling // Avatar preview - NImageRounded { + NImageCircled { width: 64 * scaling height: 64 * scaling imagePath: Settings.data.general.avatarImage fallbackIcon: "person" borderColor: Color.mPrimary - borderWidth: Math.max(1, Style.borderM) + borderWidth: Math.max(1, Style.borderM * scaling) } NTextInput { diff --git a/Modules/SidePanel/Cards/MediaCard.qml b/Modules/SidePanel/Cards/MediaCard.qml index 7809a5f..aad0839 100644 --- a/Modules/SidePanel/Cards/MediaCard.qml +++ b/Modules/SidePanel/Cards/MediaCard.qml @@ -164,7 +164,7 @@ NBox { border.width: Math.max(1, Style.borderS * scaling) clip: true - NImageRounded { + NImageCircled { id: trackArt visible: MediaService.trackArtUrl.toString() !== "" @@ -174,7 +174,6 @@ NBox { fallbackIcon: "music_note" borderColor: Color.mOutline borderWidth: Math.max(1, Style.borderS * scaling) - imageRadius: width * 0.5 } // Fallback icon when no album art available diff --git a/Modules/SidePanel/Cards/ProfileCard.qml b/Modules/SidePanel/Cards/ProfileCard.qml index b9b7df9..7374159 100644 --- a/Modules/SidePanel/Cards/ProfileCard.qml +++ b/Modules/SidePanel/Cards/ProfileCard.qml @@ -28,7 +28,7 @@ NBox { anchors.margins: Style.marginM * scaling spacing: Style.marginM * scaling - NImageRounded { + NImageCircled { width: Style.baseWidgetSize * 1.25 * scaling height: Style.baseWidgetSize * 1.25 * scaling imagePath: Settings.data.general.avatarImage diff --git a/Shaders/frag/circled_image.frag b/Shaders/frag/circled_image.frag new file mode 100644 index 0000000..308a9c5 --- /dev/null +++ b/Shaders/frag/circled_image.frag @@ -0,0 +1,30 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float imageOpacity; +} ubuf; + +void main() { + // Center coordinates around (0, 0) + vec2 uv = qt_TexCoord0 - 0.5; + + // Calculate distance from center + float distance = length(uv); + + // Create circular mask - anything beyond radius 0.5 is transparent + float mask = 1.0 - smoothstep(0.48, 0.52, distance); + + // Sample the texture + vec4 color = texture(source, qt_TexCoord0); + + // Apply the circular mask and opacity + float finalAlpha = color.a * mask * ubuf.imageOpacity * ubuf.qt_Opacity; + fragColor = vec4(color.rgb * finalAlpha, finalAlpha); +} \ No newline at end of file diff --git a/Shaders/frag/rounded_image.frag b/Shaders/frag/rounded_image.frag new file mode 100644 index 0000000..9d493b2 --- /dev/null +++ b/Shaders/frag/rounded_image.frag @@ -0,0 +1,56 @@ +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + // Custom properties with non-conflicting names + float itemWidth; + float itemHeight; + float cornerRadius; + float imageOpacity; +} ubuf; + +// Function to calculate the signed distance from a point to a rounded box +float roundedBoxSDF(vec2 centerPos, vec2 boxSize, float radius) { + vec2 d = abs(centerPos) - boxSize + radius; + return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0) - radius; +} + +void main() { + // Get size from uniforms + vec2 itemSize = vec2(ubuf.itemWidth, ubuf.itemHeight); + float cornerRadius = ubuf.cornerRadius; + float itemOpacity = ubuf.imageOpacity; + + // Normalize coordinates to [-0.5, 0.5] range + vec2 uv = qt_TexCoord0 - 0.5; + + // Scale by aspect ratio to maintain uniform rounding + vec2 aspectRatio = itemSize / max(itemSize.x, itemSize.y); + uv *= aspectRatio; + + // Calculate half size in normalized space + vec2 halfSize = 0.5 * aspectRatio; + + // Normalize the corner radius + float normalizedRadius = cornerRadius / max(itemSize.x, itemSize.y); + + // Calculate distance to rounded rectangle + float distance = roundedBoxSDF(uv, halfSize, normalizedRadius); + + // Create smooth alpha mask + float smoothedAlpha = 1.0 - smoothstep(0.0, fwidth(distance), distance); + + // Sample the texture + vec4 color = texture(source, qt_TexCoord0); + + // Apply the rounded mask and opacity + // Make sure areas outside the rounded rect are completely transparent + float finalAlpha = color.a * smoothedAlpha * itemOpacity * ubuf.qt_Opacity; + fragColor = vec4(color.rgb * finalAlpha, finalAlpha); +} \ No newline at end of file diff --git a/Shaders/qsb/circled_image.frag.qsb b/Shaders/qsb/circled_image.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..37a99ef08dbbc9febf0363349dbb46c1cdf5b81a GIT binary patch literal 1717 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 V)?6pu6>I*2BCn&be*?wwtCWWGn>qjh literal 0 HcmV?d00001 diff --git a/Widgets/NImageCircled.qml b/Widgets/NImageCircled.qml new file mode 100644 index 0000000..7220a52 --- /dev/null +++ b/Widgets/NImageCircled.qml @@ -0,0 +1,73 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import qs.Commons +import qs.Services + +Rectangle { + id: root + + property string imagePath: "" + property string fallbackIcon: "" + property color borderColor: Color.transparent + property real borderWidth: 0 + + color: Color.transparent + radius: parent.width * 0.5 + anchors.margins: Style.marginXXS * scaling + + Rectangle { + color: Color.transparent + anchors.fill: parent + + Image { + id: img + anchors.fill: parent + source: imagePath + visible: false // Hide since we're using it as shader source + mipmap: true + smooth: true + asynchronous: true + antialiasing: true + fillMode: Image.PreserveAspectCrop + } + + ShaderEffect { + anchors.fill: parent + + property var source: ShaderEffectSource { + sourceItem: img + hideSource: true + live: true + recursive: false + format: ShaderEffectSource.RGBA + } + + property real imageOpacity: root.opacity + fragmentShader: "file:Shaders/qsb/circled_image.frag.qsb" + supportsAtlasTextures: false + blending: true + } + + // Fallback icon + NIcon { + anchors.centerIn: parent + text: fallbackIcon + font.pointSize: Style.fontSizeXXL * scaling + visible: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "") + z: 0 + } + } + + //Border + Rectangle { + anchors.fill: parent + radius: parent.radius + color: Color.transparent + border.color: parent.borderColor + border.width: parent.borderWidth + antialiasing: true + z: 10 + } +} diff --git a/Widgets/NImageRounded.qml b/Widgets/NImageRounded.qml index c7fbde9..18f047f 100644 --- a/Widgets/NImageRounded.qml +++ b/Widgets/NImageRounded.qml @@ -23,13 +23,12 @@ Rectangle { Rectangle { color: Color.transparent anchors.fill: parent - anchors.margins: borderWidth Image { id: img anchors.fill: parent source: imagePath - visible: false + visible: false // Hide since we're using it as shader source mipmap: true smooth: true asynchronous: true @@ -37,24 +36,33 @@ Rectangle { fillMode: Image.PreserveAspectCrop } - MultiEffect { + ShaderEffect { anchors.fill: parent - source: img - maskEnabled: true - maskSource: mask - maskSpreadAtMax: 0.75 - visible: imagePath !== "" - } - Item { - id: mask - anchors.fill: parent - layer.enabled: true - visible: false + property var source: ShaderEffectSource { + sourceItem: img + hideSource: true + live: true + recursive: false + format: ShaderEffectSource.RGBA + } + + // Use custom property names to avoid conflicts with final properties + property real itemWidth: root.width + property real itemHeight: root.height + property real cornerRadius: root.radius + property real imageOpacity: root.opacity + fragmentShader: "file:Shaders/qsb/rounded_image.frag.qsb" + + // Qt6 specific properties - ensure proper blending + supportsAtlasTextures: false + blending: true + // Make sure the background is transparent Rectangle { + id: background anchors.fill: parent - radius: scaledRadius - antialiasing: true + color: "transparent" + z: -1 } }