diff --git a/quickshell/Modals/PowerMenuModal.qml b/quickshell/Modals/PowerMenuModal.qml index fd61f68da..3c36b712d 100644 --- a/quickshell/Modals/PowerMenuModal.qml +++ b/quickshell/Modals/PowerMenuModal.qml @@ -1,6 +1,6 @@ import QtQuick -import QtQuick.Effects import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.Common import qs.Services @@ -591,24 +591,11 @@ DankModal { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: gridProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: gridButtonRect.isHolding - layer.enabled: gridButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: gridProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left @@ -729,24 +716,11 @@ DankModal { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: listProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: listButtonRect.isHolding - layer.enabled: listButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: listProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left diff --git a/quickshell/Modules/DankDash/DankDashPopout.qml b/quickshell/Modules/DankDash/DankDashPopout.qml index 5319fc12a..849be319f 100644 --- a/quickshell/Modules/DankDash/DankDashPopout.qml +++ b/quickshell/Modules/DankDash/DankDashPopout.qml @@ -240,8 +240,10 @@ DankPopout { Connections { target: root function onShouldBeVisibleChanged() { - if (root.shouldBeVisible) - mainContainer.forceActiveFocus(); + if (!root.shouldBeVisible) + return; + mainContainer.forceActiveFocus(); + tabBar.snapIndicator(); } } diff --git a/quickshell/Modules/DankDash/MediaPlayerTab.qml b/quickshell/Modules/DankDash/MediaPlayerTab.qml index 0a57f8746..ea2345af0 100644 --- a/quickshell/Modules/DankDash/MediaPlayerTab.qml +++ b/quickshell/Modules/DankDash/MediaPlayerTab.qml @@ -25,6 +25,11 @@ Item { property string section: "" property int barPosition: SettingsData.Position.Top + readonly property color accent: MediaAccentService.accent + readonly property color onAccent: MediaAccentService.onAccent + readonly property color accentHover: MediaAccentService.accentHover + readonly property color accentPressed: MediaAccentService.accentPressed + signal showVolumeDropdown(point pos, var screen, bool rightEdge, var player, var players) signal showAudioDevicesDropdown(point pos, var screen, bool rightEdge) signal showPlayersDropdown(point pos, var screen, bool rightEdge, var player, var players) @@ -298,8 +303,16 @@ Item { Component.onCompleted: syncArt() function syncArt() { - if (curArt === "" || layerA.art == curArt || layerB.art == curArt) + if (curArt === "") return; + const frontArt = _showA ? layerA.art : layerB.art; + const backArt = _showA ? layerB.art : layerA.art; + if (frontArt == curArt) + return; + if (backArt == curArt) { + _showA = !_showA; + return; + } if (_showA) layerB.art = curArt; else @@ -564,13 +577,13 @@ Item { height: 40 radius: 20 anchors.centerIn: parent - color: shuffleArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) + color: shuffleArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0) DankIcon { anchors.centerIn: parent name: "shuffle" size: 20 - color: activePlayer && activePlayer.shuffle ? Theme.primary : Theme.surfaceText + color: activePlayer && activePlayer.shuffle ? root.accent : Theme.surfaceText } MouseArea { @@ -626,13 +639,13 @@ Item { height: 50 radius: 25 anchors.centerIn: parent - color: Theme.primary + color: root.accent DankIcon { anchors.centerIn: parent name: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" size: 28 - color: Theme.background + color: root.onAccent weight: 500 } @@ -696,7 +709,7 @@ Item { height: 40 radius: 20 anchors.centerIn: parent - color: repeatArea.containsMouse ? Theme.primaryHover : Theme.withAlpha(Theme.primaryHover, 0) + color: repeatArea.containsMouse ? root.accentHover : Theme.withAlpha(root.accent, 0) DankIcon { anchors.centerIn: parent @@ -713,7 +726,7 @@ Item { } } size: 20 - color: activePlayer && activePlayer.loopState !== MprisLoopState.None ? Theme.primary : Theme.surfaceText + color: activePlayer && activePlayer.loopState !== MprisLoopState.None ? root.accent : Theme.surfaceText } MouseArea { @@ -752,7 +765,7 @@ Item { radius: 20 x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM y: 185 - color: playerSelectorArea.containsMouse || playersExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) + color: playerSelectorArea.containsMouse || playersExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) border.color: Theme.outlineStrong border.width: 1 z: 100 @@ -819,7 +832,7 @@ Item { radius: 20 x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM y: 130 - color: volumeButtonArea.containsMouse && volumeAvailable || volumeExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) + color: volumeButtonArea.containsMouse && volumeAvailable || volumeExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) border.color: volumeAvailable ? Theme.outlineStrong : Theme.outlineMedium border.width: 1 z: 101 @@ -831,7 +844,7 @@ Item { anchors.centerIn: parent name: getVolumeIcon() size: 18 - color: volumeAvailable && currentVolume > 0 ? Theme.primary : Theme.withAlpha(Theme.surfaceText, volumeAvailable ? 1.0 : 0.5) + color: volumeAvailable && currentVolume > 0 ? root.accent : Theme.withAlpha(Theme.surfaceText, volumeAvailable ? 1.0 : 0.5) } MouseArea { @@ -882,7 +895,7 @@ Item { radius: 20 x: isRightEdge ? Theme.spacingM : parent.width - 40 - Theme.spacingM y: 240 - color: audioDevicesArea.containsMouse || devicesExpanded ? Theme.primaryPressed : Theme.withAlpha(Theme.primaryPressed, 0) + color: audioDevicesArea.containsMouse || devicesExpanded ? root.accentPressed : Theme.withAlpha(root.accentPressed, 0) border.color: Theme.outlineStrong border.width: 1 z: 100 diff --git a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml index 648e3ebdf..c2cbed7fc 100644 --- a/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/MediaOverviewCard.qml @@ -154,13 +154,13 @@ Card { width: 32 height: 32 radius: 16 - color: Theme.primary + color: MediaAccentService.accent DankIcon { anchors.centerIn: parent name: activePlayer?.playbackState === MprisPlaybackState.Playing ? "pause" : "play_arrow" size: 16 - color: Theme.background + color: MediaAccentService.onAccent } MouseArea { diff --git a/quickshell/Modules/Lock/LockPowerMenu.qml b/quickshell/Modules/Lock/LockPowerMenu.qml index 2f20d1f11..a9898f217 100644 --- a/quickshell/Modules/Lock/LockPowerMenu.qml +++ b/quickshell/Modules/Lock/LockPowerMenu.qml @@ -1,7 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Effects +import Quickshell.Widgets import qs.Common import qs.Services import qs.Widgets @@ -567,24 +567,11 @@ Rectangle { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: gridProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: gridButtonRect.isHolding - layer.enabled: gridButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: gridProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left @@ -700,24 +687,11 @@ Rectangle { border.color: isSelected ? Theme.primary : Theme.withAlpha(Theme.primary, 0) border.width: isSelected ? 2 : 0 - Rectangle { - id: listProgressMask + ClippingRectangle { anchors.fill: parent radius: parent.radius - visible: false - layer.enabled: true - } - - Item { - anchors.fill: parent + color: "transparent" visible: listButtonRect.isHolding - layer.enabled: listButtonRect.isHolding - layer.effect: MultiEffect { - maskEnabled: true - maskSource: listProgressMask - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } Rectangle { anchors.left: parent.left diff --git a/quickshell/Modules/OSD/MediaPlaybackOSD.qml b/quickshell/Modules/OSD/MediaPlaybackOSD.qml index 5d92388f7..91c602ddf 100644 --- a/quickshell/Modules/OSD/MediaPlaybackOSD.qml +++ b/quickshell/Modules/OSD/MediaPlaybackOSD.qml @@ -4,6 +4,7 @@ import qs.Common import qs.Services import qs.Widgets import Quickshell.Services.Mpris +import Quickshell.Widgets DankOSD { id: root @@ -185,10 +186,11 @@ DankOSD { visible: false } - Item { - id: blurredBg + ClippingRectangle { anchors.fill: parent - visible: false + radius: Theme.cornerRadius + color: "transparent" + opacity: 0.7 MultiEffect { anchors.centerIn: parent @@ -203,24 +205,6 @@ DankOSD { } } - Rectangle { - id: bgMask - anchors.fill: parent - radius: Theme.cornerRadius - visible: false - layer.enabled: true - } - - MultiEffect { - anchors.fill: parent - source: blurredBg - maskEnabled: true - maskSource: bgMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1.0 - opacity: 0.7 - } - Rectangle { anchors.fill: parent radius: Theme.cornerRadius diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index ac35d0fc8..808cedabb 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -1,7 +1,7 @@ import QtCore import QtQuick -import QtQuick.Effects import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.FileBrowser import qs.Services @@ -520,28 +520,27 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = Theme.wallpaperPath; - if (!wp || wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: Theme.wallpaperPath && !Theme.wallpaperPath.startsWith("#") - sourceSize.width: 120 - sourceSize.height: 120 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: autoWallpaperMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = Theme.wallpaperPath; + if (!wp || wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: Theme.wallpaperPath && !Theme.wallpaperPath.startsWith("#") + sourceSize.width: 120 + sourceSize.height: 120 + asynchronous: true } } @@ -553,16 +552,6 @@ Item { visible: Theme.wallpaperPath && Theme.wallpaperPath.startsWith("#") } - Rectangle { - id: autoWallpaperMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: (ToastService.wallpaperErrorStatus === "error" || ToastService.wallpaperErrorStatus === "matugen_missing") ? "error" : "palette" diff --git a/quickshell/Modules/Settings/WallpaperTab.qml b/quickshell/Modules/Settings/WallpaperTab.qml index bf054b095..90afda2c3 100644 --- a/quickshell/Modules/Settings/WallpaperTab.qml +++ b/quickshell/Modules/Settings/WallpaperTab.qml @@ -1,6 +1,6 @@ import QtQuick -import QtQuick.Effects import Quickshell +import Quickshell.Widgets import qs.Common import qs.Modals.FileBrowser import qs.Services @@ -75,28 +75,27 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = root.currentWallpaper; - if (wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: root.currentWallpaper !== "" && !root.currentWallpaper.startsWith("#") - sourceSize.width: 160 - sourceSize.height: 160 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: wallpaperMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = root.currentWallpaper; + if (wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: root.currentWallpaper !== "" && !root.currentWallpaper.startsWith("#") + sourceSize.width: 160 + sourceSize.height: 160 + asynchronous: true } } @@ -108,16 +107,6 @@ Item { visible: root.currentWallpaper !== "" && root.currentWallpaper.startsWith("#") } - Rectangle { - id: wallpaperMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: "image" @@ -421,31 +410,30 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = SessionData.wallpaperPathLight; - if (wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: { - var lightWallpaper = SessionData.wallpaperPathLight; - return lightWallpaper !== "" && !lightWallpaper.startsWith("#"); - } - sourceSize.width: 160 - sourceSize.height: 160 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: lightMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = SessionData.wallpaperPathLight; + if (wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: { + var lightWallpaper = SessionData.wallpaperPathLight; + return lightWallpaper !== "" && !lightWallpaper.startsWith("#"); + } + sourceSize.width: 160 + sourceSize.height: 160 + asynchronous: true } } @@ -463,16 +451,6 @@ Item { } } - Rectangle { - id: lightMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: "light_mode" @@ -611,31 +589,30 @@ Item { radius: Theme.cornerRadius color: Theme.surfaceVariant - Image { + ClippingRectangle { anchors.fill: parent anchors.margins: 1 - source: { - var wp = SessionData.wallpaperPathDark; - if (wp === "" || wp.startsWith("#")) - return ""; - if (wp.startsWith("file://")) - wp = wp.substring(7); - return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); - } - fillMode: Image.PreserveAspectCrop - visible: { - var darkWallpaper = SessionData.wallpaperPathDark; - return darkWallpaper !== "" && !darkWallpaper.startsWith("#"); - } - sourceSize.width: 160 - sourceSize.height: 160 - asynchronous: true - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: darkMask - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + radius: Theme.cornerRadius - 1 + color: "transparent" + + Image { + anchors.fill: parent + source: { + var wp = SessionData.wallpaperPathDark; + if (wp === "" || wp.startsWith("#")) + return ""; + if (wp.startsWith("file://")) + wp = wp.substring(7); + return "file://" + wp.split('/').map(s => encodeURIComponent(s)).join('/'); + } + fillMode: Image.PreserveAspectCrop + visible: { + var darkWallpaper = SessionData.wallpaperPathDark; + return darkWallpaper !== "" && !darkWallpaper.startsWith("#"); + } + sourceSize.width: 160 + sourceSize.height: 160 + asynchronous: true } } @@ -653,16 +630,6 @@ Item { } } - Rectangle { - id: darkMask - anchors.fill: parent - anchors.margins: 1 - radius: Theme.cornerRadius - 1 - color: "black" - visible: false - layer.enabled: true - } - DankIcon { anchors.centerIn: parent name: "dark_mode" diff --git a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml index aea653d58..d8fc38642 100644 --- a/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml +++ b/quickshell/Modules/WorkspaceOverlays/OverviewWindow.qml @@ -1,8 +1,8 @@ import QtQuick -import QtQuick.Effects import QtQuick.Layouts import Quickshell import Quickshell.Wayland +import Quickshell.Widgets import qs.Common Item { @@ -58,23 +58,6 @@ Item { visible: intersectsViewport opacity: (monitorObj?.id ?? -1) == widgetMonitorId ? 1 : 0.4 - Rectangle { - id: maskRect - width: root.width - height: root.height - radius: Theme.cornerRadius - visible: false - layer.enabled: true - } - - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: maskRect - maskSpreadAtMin: 1 - maskThresholdMin: 0.5 - } - Behavior on x { NumberAnimation { duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) @@ -104,51 +87,55 @@ Item { } } - ScreencopyView { - id: windowPreview + ClippingRectangle { anchors.fill: parent - captureSource: root.overviewOpen ? root.toplevel?.wayland : null - live: true + radius: Theme.cornerRadius + color: "transparent" - Rectangle { + ScreencopyView { + id: windowPreview anchors.fill: parent - radius: Theme.cornerRadius - color: pressed ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) : - hovered ? Theme.withAlpha(Theme.surfaceVariant, 0.3) : - Theme.withAlpha(Theme.surfaceContainer, 0.1) - border.color: Theme.withAlpha(Theme.outline, 0.3) - border.width: 1 - } + captureSource: root.overviewOpen ? root.toplevel?.wayland : null + live: true - ColumnLayout { - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.right: parent.right - spacing: Theme.fontSizeSmall * 0.5 + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: pressed ? Theme.withAlpha(Theme.surfaceContainerHigh, 0.5) : hovered ? Theme.withAlpha(Theme.surfaceVariant, 0.3) : Theme.withAlpha(Theme.surfaceContainer, 0.1) + border.color: Theme.withAlpha(Theme.outline, 0.3) + border.width: 1 + } - Image { - id: windowIcon - property var iconSize: { - return Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / (root.monitorData?.scale ?? 1) - } - Layout.alignment: Qt.AlignHCenter - source: root.iconPath - width: iconSize - height: iconSize - sourceSize: Qt.size(iconSize, iconSize) + ColumnLayout { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.fontSizeSmall * 0.5 - Behavior on width { - NumberAnimation { - duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.variantModalEnterCurve + Image { + id: windowIcon + property var iconSize: { + return Math.min(targetWindowWidth, targetWindowHeight) * (root.compactMode ? root.iconToWindowRatioCompact : root.iconToWindowRatio) / (root.monitorData?.scale ?? 1); } - } - Behavior on height { - NumberAnimation { - duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.variantModalEnterCurve + Layout.alignment: Qt.AlignHCenter + source: root.iconPath + width: iconSize + height: iconSize + sourceSize: Qt.size(iconSize, iconSize) + + Behavior on width { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.variantModalEnterCurve + } + } + Behavior on height { + NumberAnimation { + duration: Theme.variantDuration(Theme.expressiveDurations.expressiveDefaultSpatial, overviewOpen) + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.variantModalEnterCurve + } } } } diff --git a/quickshell/Services/MediaAccentService.qml b/quickshell/Services/MediaAccentService.qml new file mode 100644 index 000000000..1c7d0de87 --- /dev/null +++ b/quickshell/Services/MediaAccentService.qml @@ -0,0 +1,73 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import qs.Common +import qs.Services + +// Accent color extracted from the current track's album art via ColorQuantizer, +// falling back to Theme.primary when no usable accent is available. +Singleton { + id: root + + readonly property bool hasAccent: _accent !== null + readonly property color accent: _accent !== null ? _accent : Theme.primary + + readonly property color onAccent: { + const c = accent; + const lum = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + return lum > 0.6 ? Qt.rgba(0, 0, 0, 1) : Qt.rgba(1, 1, 1, 1); + } + + readonly property color accentHover: Theme.withAlpha(accent, 0.12) + readonly property color accentPressed: Theme.withAlpha(accent, Theme.transparentBlurLayers ? 0.24 : 0.16) + + // Plain-named alias: underscore-prefixed props with onChanged handlers crash config load. + readonly property string artUrl: TrackArtService.resolvedArtUrl + onArtUrlChanged: { + if (artUrl === "") + _accent = null; + } + + property var _accent: null + + ColorQuantizer { + id: quantizer + source: root.artUrl + depth: 4 + rescaleSize: 64 + onColorsChanged: root._accent = root._pickAccent(colors) + } + + function _pickAccent(colors) { + if (!colors || colors.length === 0) + return null; + + let best = null; + let bestScore = -1; + for (let i = 0; i < colors.length; i++) { + const c = colors[i]; + const s = c.hsvSaturation; + const v = c.hsvValue; + if (v < 0.22 || v > 0.96 || s < 0.22) + continue; + const score = s * (1 - Math.abs(v - 0.68)); + if (score > bestScore) { + bestScore = score; + best = c; + } + } + + if (!best) + return null; + return _normalize(best); + } + + function _normalize(c) { + const hue = c.hsvHue < 0 ? 0 : c.hsvHue; + const s = Math.min(1, c.hsvSaturation * 1.05); + const v = Math.max(c.hsvValue, 0.62); + return Qt.hsva(hue, s, v, 1); + } +} diff --git a/quickshell/Shaders/frag/wave_progress.frag b/quickshell/Shaders/frag/wave_progress.frag new file mode 100644 index 000000000..0fe6a1bed --- /dev/null +++ b/quickshell/Shaders/frag/wave_progress.frag @@ -0,0 +1,115 @@ +#version 450 + +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; + float widthPx; + float heightPx; + float value; + float actualValue; + float phase; + float ampPx; + float wavelengthPx; + float lineWidthPx; + float showActual; + vec4 fillColor; + vec4 trackColor; + vec4 playheadColor; + vec4 actualColor; +} ubuf; + +const float TAU = 6.28318530718; +const float AA = 0.75; // pixel-space antialias band + +// Signed distance to a rounded box centered at the origin. +float sdRoundBar(vec2 p, vec2 halfSize, float r) { + vec2 q = abs(p) - halfSize + vec2(r); + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +// Composite a straight-alpha color over a premultiplied accumulator. +vec4 blendOver(vec4 dst, vec3 rgb, float a) { + return vec4(rgb * a + dst.rgb * (1.0 - a), a + dst.a * (1.0 - a)); +} + +void main() { + float w = ubuf.widthPx; + float h = ubuf.heightPx; + vec2 px = vec2(qt_TexCoord0.x * w, qt_TexCoord0.y * h); + + float mid = h * 0.5; + float halfW = ubuf.lineWidthPx * 0.5; + float k = TAU / max(ubuf.wavelengthPx, 1e-3); + + float playX = clamp(ubuf.value, 0.0, 1.0) * w; + float actualX = clamp(ubuf.actualValue, 0.0, 1.0) * w; + bool seeking = ubuf.showActual > 0.5; + + float loX = min(playX, actualX); + float hiX = max(playX, actualX); + float fillEnd = seeking ? loX : playX; // filled progress ends here + float actStart = seeking ? loX : playX; // seek-preview segment + float actEnd = seeking ? hiX : playX; + float trackStart = seeking ? hiX : playX; // unplayed remainder + + // Perpendicular distance to the animated sine stroke. + float ang = k * px.x + ubuf.phase; + float wy = mid + ubuf.ampPx * sin(ang); + float slope = ubuf.ampPx * k * cos(ang); + float dWave = abs(px.y - wy) / sqrt(1.0 + slope * slope); + float aaW = AA; + float waveStroke = 1.0 - smoothstep(halfW - aaW, halfW + aaW, dWave); + + // Straight remainder line. + float dLine = abs(px.y - mid); + float aaL = AA; + float lineStroke = 1.0 - smoothstep(halfW - aaL, halfW + aaL, dLine); + + vec4 col = vec4(0.0); + + // 1. Track (unplayed remainder), to the right of the progress head. + { + float m = lineStroke * step(trackStart, px.x); + col = blendOver(col, ubuf.trackColor.rgb, ubuf.trackColor.a * m); + } + + // 2. Seek-preview segment (only while seeking). + if (seeking) { + float m = waveStroke * step(actStart, px.x) * step(px.x, actEnd); + col = blendOver(col, ubuf.actualColor.rgb, ubuf.actualColor.a * m); + } + + // 3. Filled progress wave. + { + float m = waveStroke * step(halfW, px.x) * step(px.x, fillEnd); + // Rounded start cap. + float capS = length(px - vec2(halfW, mid + ubuf.ampPx * sin(k * halfW + ubuf.phase))) - halfW; + float capM = 1.0 - smoothstep(-aaW, aaW, capS); + m = max(m, capM * step(halfW - 1.0, px.x)); + col = blendOver(col, ubuf.fillColor.rgb, ubuf.fillColor.a * m); + } + + // 4. Actual-position marker (only while seeking). + if (seeking) { + float amH = max(ubuf.lineWidthPx + 4.0, 10.0); + float d = sdRoundBar(px - vec2(actualX, mid), vec2(1.0, amH * 0.5), 1.0); + float aa = AA; + float m = 1.0 - smoothstep(-aa, aa, d); + col = blendOver(col, ubuf.actualColor.rgb, ubuf.actualColor.a * m); + } + + // 5. Playhead pill (on top). + { + float phW = 3.5; + float phH = max(ubuf.lineWidthPx + 12.0, 16.0); + float d = sdRoundBar(px - vec2(playX, mid), vec2(phW * 0.5, phH * 0.5), phW * 0.5); + float aa = AA; + float m = 1.0 - smoothstep(-aa, aa, d); + col = blendOver(col, ubuf.playheadColor.rgb, ubuf.playheadColor.a * m); + } + + fragColor = col * ubuf.qt_Opacity; +} diff --git a/quickshell/Shaders/qsb/wave_progress.frag.qsb b/quickshell/Shaders/qsb/wave_progress.frag.qsb new file mode 100644 index 000000000..2347db107 Binary files /dev/null and b/quickshell/Shaders/qsb/wave_progress.frag.qsb differ diff --git a/quickshell/Widgets/DankAlbumArt.qml b/quickshell/Widgets/DankAlbumArt.qml index b01c13e7b..ab3f3384e 100644 --- a/quickshell/Widgets/DankAlbumArt.qml +++ b/quickshell/Widgets/DankAlbumArt.qml @@ -152,7 +152,8 @@ Item { property real energy: 0 property vector4d bandsA: Qt.vector4d(0, 0, 0, 0) property vector2d bandsB: Qt.vector2d(0, 0) - property vector4d fillColor: Qt.vector4d(Theme.primary.r, Theme.primary.g, Theme.primary.b, Theme.primary.a) + readonly property color accentColor: MediaAccentService.accent + property vector4d fillColor: Qt.vector4d(accentColor.r, accentColor.g, accentColor.b, accentColor.a) Behavior on activation { NumberAnimation { @@ -173,7 +174,7 @@ Item { imageSource: artUrl || lastValidArtUrl || "" fallbackIcon: "album" - border.color: Theme.primary + border.color: MediaAccentService.accent border.width: 2 onImageSourceChanged: { diff --git a/quickshell/Widgets/DankCircularImage.qml b/quickshell/Widgets/DankCircularImage.qml index 09805c33b..f88ac1c45 100644 --- a/quickshell/Widgets/DankCircularImage.qml +++ b/quickshell/Widgets/DankCircularImage.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Window -import QtQuick.Effects +import Quickshell.Widgets import qs.Common import qs.Widgets @@ -65,67 +65,44 @@ Rectangle { border.color: "transparent" border.width: 0 - // Probes as AnimatedImage to read frameCount; retires once staticImage is ready. - AnimatedImage { - id: probe + ClippingRectangle { anchors.fill: parent anchors.margins: 2 - asynchronous: true - fillMode: Image.PreserveAspectCrop - smooth: true - mipmap: true - cache: root.cacheImages - visible: false - source: root.shouldProbe && (root.isAnimated || staticImage.status !== Image.Ready) ? root.imageSource : "" - } + radius: Math.min(width, height) / 2 + color: "transparent" - // Takes over once the probe settles on a non-animated image, then latches. - Image { - id: staticImage - anchors.fill: parent - anchors.margins: 2 - asynchronous: true - fillMode: Image.PreserveAspectCrop - smooth: true - mipmap: true - cache: root.cacheImages - visible: false - sourceSize.width: Math.max(width * 2, 128) - sourceSize.height: Math.max(height * 2, 128) - source: { - if (!root.shouldProbe) - return root.imageSource; - if ((root.probeSettled && !root.isAnimated) || staticImage.status !== Image.Null) - return root.imageSource; - return ""; - } - } - - MultiEffect { - anchors.fill: parent - anchors.margins: 2 - source: root.activeImage - maskEnabled: true - maskSource: circularMask - visible: root.activeImage.status === Image.Ready && root.imageSource !== "" - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 - } - - Item { - id: circularMask - anchors.centerIn: parent - width: parent.width - 4 - height: parent.height - 4 - layer.enabled: true - layer.smooth: true - visible: false - - Rectangle { + // Probes as AnimatedImage to read frameCount; retires once staticImage is ready. + AnimatedImage { + id: probe anchors.fill: parent - radius: width / 2 - color: "black" - antialiasing: true + asynchronous: true + fillMode: Image.PreserveAspectCrop + smooth: true + mipmap: true + cache: root.cacheImages + visible: root.activeImage === probe && probe.status === Image.Ready && root.imageSource !== "" + source: root.shouldProbe && (root.isAnimated || staticImage.status !== Image.Ready) ? root.imageSource : "" + } + + // Takes over once the probe settles on a non-animated image, then latches. + Image { + id: staticImage + anchors.fill: parent + asynchronous: true + fillMode: Image.PreserveAspectCrop + smooth: true + mipmap: true + cache: root.cacheImages + visible: root.activeImage === staticImage && staticImage.status === Image.Ready && root.imageSource !== "" + sourceSize.width: Math.max(width * 2, 128) + sourceSize.height: Math.max(height * 2, 128) + source: { + if (!root.shouldProbe) + return root.imageSource; + if ((root.probeSettled && !root.isAnimated) || staticImage.status !== Image.Null) + return root.imageSource; + return ""; + } } } diff --git a/quickshell/Widgets/DankSeekbar.qml b/quickshell/Widgets/DankSeekbar.qml index fa36728c3..ee301a32a 100644 --- a/quickshell/Widgets/DankSeekbar.qml +++ b/quickshell/Widgets/DankSeekbar.qml @@ -142,6 +142,8 @@ Item { value: root.value actualValue: root.playerValue showActualPlaybackState: root.isSeeking + fillColor: MediaAccentService.accent + playheadColor: MediaAccentService.accent actualProgressColor: Theme.onSurface_38 isPlaying: activePlayer && activePlayer.playbackState === MprisPlaybackState.Playing @@ -179,8 +181,8 @@ Item { Item { property real lineWidth: 3 property color trackColor: Theme.withAlpha(Theme.surfaceVariant, 0.40) - property color fillColor: Theme.primary - property color playheadColor: Theme.primary + property color fillColor: MediaAccentService.accent + property color playheadColor: MediaAccentService.accent property color actualProgressColor: Theme.onSurface_38 readonly property real midY: height / 2 diff --git a/quickshell/Widgets/DankTabBar.qml b/quickshell/Widgets/DankTabBar.qml index d3b72f122..eadef0885 100644 --- a/quickshell/Widgets/DankTabBar.qml +++ b/quickshell/Widgets/DankTabBar.qml @@ -256,6 +256,11 @@ FocusScope { } } + function snapIndicator() { + indicator.initialSetupComplete = false; + updateIndicator(); + } + onCurrentIndexChanged: { Qt.callLater(updateIndicator); } diff --git a/quickshell/Widgets/M3WaveProgress.qml b/quickshell/Widgets/M3WaveProgress.qml index d6ec1e450..a35a4562f 100644 --- a/quickshell/Widgets/M3WaveProgress.qml +++ b/quickshell/Widgets/M3WaveProgress.qml @@ -1,7 +1,8 @@ import QtQuick -import QtQuick.Shapes import qs.Common +// Wave progress indicator: track, animated fill, seek preview and playhead are +// all drawn in a single fragment shader (Shaders/frag/wave_progress.frag). Item { id: root @@ -19,19 +20,6 @@ Item { property color playheadColor: Theme.primary property color actualProgressColor: Theme.onSurface_38 - property real dpr: (root.window ? root.window.devicePixelRatio : 1) - function snap(v) { - return Math.round(v * dpr) / dpr; - } - - readonly property real playX: snap(root.width * root.value) - readonly property real actualX: snap(root.width * root.actualValue) - readonly property real midY: snap(height / 2) - readonly property bool previewAhead: root.showActualPlaybackState && root.value > root.actualValue - readonly property bool previewBehind: root.showActualPlaybackState && root.value < root.actualValue - readonly property real previewGapStartX: Math.min(root.playX, root.actualX) - readonly property real previewGapEndX: Math.max(root.playX, root.actualX) - Behavior on currentAmp { NumberAnimation { duration: 300 @@ -39,236 +27,34 @@ Item { } } onIsPlayingChanged: currentAmp = isPlaying ? amp : 0 + Component.onCompleted: currentAmp = isPlaying ? amp : 0 - Shape { - id: flatTrack + ShaderEffect { anchors.fill: parent - antialiasing: true - preferredRendererType: Shape.CurveRenderer - layer.enabled: true + blending: true - ShapePath { - strokeColor: root.trackColor - strokeWidth: snap(root.lineWidth) - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - fillColor: "transparent" - PathMove { - id: flatStart - x: Math.min(root.width, snap(root.playX + playhead.width / 2)) - y: root.midY - } - PathLine { - id: flatEnd - x: root.width - y: root.midY - } - } + readonly property real widthPx: width + readonly property real heightPx: height + readonly property real value: root.value + readonly property real actualValue: root.actualValue + readonly property real phase: root.phase + readonly property real ampPx: root.currentAmp + readonly property real wavelengthPx: root.wavelength + readonly property real lineWidthPx: root.lineWidth + readonly property real showActual: root.showActualPlaybackState ? 1.0 : 0.0 + readonly property color fillColor: root.fillColor + readonly property color trackColor: root.trackColor + readonly property color playheadColor: root.playheadColor + readonly property color actualColor: root.actualProgressColor + + fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wave_progress.frag.qsb") } - Item { - id: waveClip - anchors.fill: parent - clip: true - - readonly property real startX: snap(root.lineWidth / 2) - readonly property real aaBias: (0.25 / root.dpr) - readonly property real endX: root.previewAhead ? Math.max(startX, Math.min(root.actualX - aaBias, width)) : Math.max(startX, Math.min(root.playX - startX - aaBias, width)) - readonly property real gapStartX: root.previewAhead ? Math.max(startX, Math.min(root.actualX + aaBias, width)) : Math.max(startX, Math.min(root.playX + playhead.width / 2, width)) - readonly property real gapEndX: root.previewAhead ? Math.max(gapStartX, Math.min(root.playX - playhead.width / 2 - aaBias, width)) : Math.max(gapStartX, Math.min(root.actualX - aaBias, width)) - - Rectangle { - id: mask - anchors.top: parent.top - anchors.bottom: parent.bottom - x: 0 - width: waveClip.endX - color: "transparent" - clip: true - - Shape { - id: waveShape - anchors.top: parent.top - anchors.bottom: parent.bottom - width: parent.width + 4 * root.wavelength - antialiasing: true - preferredRendererType: Shape.CurveRenderer - x: waveOffsetX - - ShapePath { - id: wavePath - strokeColor: root.fillColor - strokeWidth: snap(root.lineWidth) - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - fillColor: "transparent" - PathSvg { - id: waveSvg - path: "" - } - } - } - } - - Rectangle { - id: actualMask - anchors.top: parent.top - anchors.bottom: parent.bottom - x: waveClip.gapStartX - width: Math.max(0, waveClip.gapEndX - waveClip.gapStartX) - color: "transparent" - clip: true - visible: (root.previewBehind || root.previewAhead) && width > 0 - - Shape { - anchors.top: parent.top - anchors.bottom: parent.bottom - width: root.width + 4 * root.wavelength - antialiasing: true - preferredRendererType: Shape.CurveRenderer - x: waveOffsetX - - ShapePath { - strokeColor: root.actualProgressColor - strokeWidth: snap(root.lineWidth) - capStyle: ShapePath.RoundCap - joinStyle: ShapePath.RoundJoin - fillColor: "transparent" - PathSvg { - path: waveSvg.path - } - } - } - } - - Rectangle { - id: startCap - width: snap(root.lineWidth) - height: snap(root.lineWidth) - radius: width / 2 - color: root.fillColor - x: waveClip.startX - width / 2 - y: waveY(waveClip.startX) - height / 2 - visible: waveClip.endX > waveClip.startX - z: 2 - } - - Rectangle { - id: endCap - width: snap(root.lineWidth) - height: snap(root.lineWidth) - radius: width / 2 - color: root.fillColor - x: waveClip.endX - width / 2 - y: waveY(waveClip.endX) - height / 2 - visible: waveClip.endX > waveClip.startX - z: 2 - } - - Rectangle { - id: actualEndCap - width: snap(root.lineWidth) - height: snap(root.lineWidth) - radius: width / 2 - color: root.actualProgressColor - x: waveClip.gapEndX - width / 2 - y: waveY(waveClip.gapEndX) - height / 2 - visible: (root.previewBehind || root.previewAhead) && actualMask.width > 0 - z: 2 - } - - Rectangle { - id: actualMarker - width: 2 - height: Math.max(root.lineWidth + 4, 10) - radius: width / 2 - color: root.actualProgressColor - x: root.actualX - width / 2 - y: root.midY - height / 2 - visible: root.showActualPlaybackState - z: 2 - } - } - - Rectangle { - id: playhead - width: 3.5 - height: Math.max(root.lineWidth + 12, 16) - radius: width / 2 - color: root.playheadColor - x: root.playX - width / 2 - y: root.midY - height / 2 - z: 3 - } - - property real k: (2 * Math.PI) / Math.max(1e-6, wavelength) - function wrapMod(a, m) { - let r = a % m; - return r < 0 ? r + m : r; - } - function waveY(x, amplitude = root.currentAmp, phaseOffset = root.phase) { - return root.midY + amplitude * Math.sin((x / root.wavelength) * 2 * Math.PI + phaseOffset); - } - - readonly property real waveOffsetX: -wrapMod(phase / k, wavelength) - FrameAnimation { running: root.visible && (root.isPlaying || root.currentAmp > 0) onTriggered: { if (root.isPlaying) root.phase += 0.03 * frameTime * 60; - startCap.y = waveY(waveClip.startX) - startCap.height / 2; - endCap.y = waveY(waveClip.endX) - endCap.height / 2; - actualEndCap.y = waveY(waveClip.gapEndX) - actualEndCap.height / 2; } } - - function buildStaticWave() { - const start = waveClip.startX - 2 * root.wavelength; - const end = width + 2 * root.wavelength; - if (end <= start) { - waveSvg.path = ""; - return; - } - - const kLocal = k; - const halfPeriod = root.wavelength / 2; - function y0(x) { - return root.midY + root.currentAmp * Math.sin(kLocal * x); - } - function dy0(x) { - return root.currentAmp * Math.cos(kLocal * x) * kLocal; - } - - let x0 = start; - let d = `M ${x0} ${y0(x0)}`; - while (x0 < end) { - const x1 = Math.min(x0 + halfPeriod, end); - const dx = x1 - x0; - const yA = y0(x0), yB = y0(x1); - const dyA = dy0(x0), dyB = dy0(x1); - const c1x = x0 + dx / 3; - const c1y = yA + (dyA * dx) / 3; - const c2x = x1 - dx / 3; - const c2y = yB - (dyB * dx) / 3; - d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${x1} ${yB}`; - x0 = x1; - } - waveSvg.path = d; - } - - Component.onCompleted: { - currentAmp = isPlaying ? amp : 0; - buildStaticWave(); - } - onWidthChanged: { - flatEnd.x = width; - buildStaticWave(); - } - onHeightChanged: buildStaticWave() - onCurrentAmpChanged: buildStaticWave() - onWavelengthChanged: { - k = (2 * Math.PI) / Math.max(1e-6, wavelength); - buildStaticWave(); - } }