diff --git a/README.md b/README.md index 7605bcc..590be35 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,6 @@ Niri & Quickshell -https://github.com/user-attachments/assets/7e2db305-58bc-4b3d-9c65-7dc0461aead7 -
@@ -48,11 +46,11 @@ https://github.com/user-attachments/assets/7e2db305-58bc-4b3d-9c65-7dc0461aead7 - Shell: **Fish** - Prompt: **Oh My Posh** - Terminal: **Kitty** & (**WezTerm** | Ghostty) -- Power Menu: **Wlogout** +- Power Menu: **Wlogout** & Quickshell - Colorscheme: **Catppuccin Mocha** - App Launcher: **Rofi** | Fuzzel - Desktop Widgets: Eww | **Quickshell** -- Wallpaper Daemon: **Awww** (previously Swww) +- Wallpaper Daemon: Awww | **Quickshell** - Notification Daemon: Mako | **Quickshell** (**bold**: currently preferred) @@ -67,7 +65,7 @@ Ported from Hyprland, and shares some of the desktop components such as hyprlock ## Quickshell -Not based on, but heavily depends on many modules from [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell). A thousand thanks to their great work. +Not based on, but heavily depends on many modules from (an old version of) [noctalia-shell](https://github.com/noctalia-dev/noctalia-shell). A thousand thanks to their great work. This setup is currently only adapted for Niri. @@ -80,8 +78,6 @@ This setup is currently only adapted for Niri. ## Wallpaper & Colortheme - [WallReel](https://github.com/Uyanide/WallReel): an Image Carousel implemented with QtQuick to browse and set wallpapers from. -- [wallpaper-daemon](./config/scripts/.local/scripts/wallpaper-daemon): automatic blur (only works in niri). -- [change-wallpaper](./config/scripts/.local/scripts/change-wallpaper): script that changes wallpaper with a few extra features. - [change-colortheme](./config/scripts/.local/scripts/change-colortheme): script that extract colors from the current wallpaper and generate a catppuccin color scheme accordingly. - [backgrounds](https://github.com/Uyanide/backgrounds) collection for personal use (mostly waifus). diff --git a/config/niri/.config/niri/config/binds.kdl b/config/niri/.config/niri/config/binds.kdl index 27df1c4..4c7105c 100644 --- a/config/niri/.config/niri/config/binds.kdl +++ b/config/niri/.config/niri/config/binds.kdl @@ -21,11 +21,12 @@ binds { Mod+Return { spawn "kitty"; } Mod+Shift+W { spawn "wallreel"; } Mod+O { spawn-sh "pkill -x -n pwvucontrol || pwvucontrol"; } + Ctrl+Alt+Delete { spawn-sh "pkill -x wlogout || wlogout"; } // Quickshell - Mod+Space { spawn "qs" "ipc" "call" "panels" "toggleControlCenter"; } - Mod+Shift+D { spawn "qs" "ipc" "call" "panels" "toggleCalendar"; } - Mod+Shift+L { spawn "qs" "ipc" "call" "lyrics" "toggleBarLyrics"; } + Mod+Space { spawn "qs" "ipc" "call" "bars" "toggleLeft"; } + Mod+N { spawn "qs" "ipc" "call" "bars" "toggleRight"; } + Mod+Shift+L { spawn "qs" "ipc" "call" "bars" "toggleLyrics"; } Mod+Shift+K { spawn-sh "quickshell-kill || quickshell"; } Mod+I { spawn "qs" "ipc" "call" "idleInhibitor" "toggleInhibitor"; } Mod+Alt+R { spawn "qs" "ipc" "call" "recording" "startOrStopRecording"; } @@ -38,7 +39,6 @@ binds { // Actions Mod+V { spawn-sh "fzfclip-wrap"; } Mod+Period { spawn-sh "pkill -x rofi || rofi-emoji"; } - Ctrl+Alt+Delete { spawn-sh "pkill -x wlogout || wlogout -p layer-shell"; } Print { spawn "niri" "msg" "action" "screenshot-screen"; } Mod+Shift+S { spawn "niri" "msg" "action" "screenshot"; } Mod+Ctrl+Shift+S { spawn "niri" "msg" "action" "screenshot-window"; } @@ -48,18 +48,18 @@ binds { Mod+L { spawn "loginctl" "lock-session"; } // Media - XF86AudioRaiseVolume allow-when-locked=true { spawn-sh "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"; } - XF86AudioLowerVolume allow-when-locked=true { spawn-sh "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%-"; } - XF86AudioMute allow-when-locked=true { spawn-sh "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"; } - XF86AudioMicMute allow-when-locked=true { spawn-sh "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"; } - XF86AudioPlay allow-when-locked=true { spawn-sh "playerctl play-pause"; } - XF86AudioPause allow-when-locked=true { spawn-sh "playerctl play-pause"; } - XF86AudioNext allow-when-locked=true { spawn-sh "playerctl next"; } - XF86AudioPrev allow-when-locked=true { spawn-sh "playerctl previous"; } + XF86AudioRaiseVolume allow-when-locked=true { spawn "qs" "ipc" "call" "media" "volumeUp"; } + XF86AudioLowerVolume allow-when-locked=true { spawn "qs" "ipc" "call" "media" "volumeDown"; } + XF86AudioMute allow-when-locked=true { spawn "qs" "ipc" "call" "media" "toggleOutputMute"; } + XF86AudioMicMute allow-when-locked=true { spawn "qs" "ipc" "call" "media" "toggleInputMute"; } + XF86AudioPlay allow-when-locked=true { spawn "qs" "ipc" "call" "media" "playPause"; } + XF86AudioPause allow-when-locked=true { spawn "qs" "ipc" "call" "media" "playPause"; } + XF86AudioNext allow-when-locked=true { spawn "qs" "ipc" "call" "media" "next"; } + XF86AudioPrev allow-when-locked=true { spawn "qs" "ipc" "call" "media" "previous"; } // Brightness - XF86MonBrightnessUp allow-when-locked=true { spawn "set-brightness" "+10%"; } - XF86MonBrightnessDown allow-when-locked=true { spawn "set-brightness" "10%-"; } + XF86MonBrightnessUp allow-when-locked=true { spawn "qs" "ipc" "call" "brightness" "brightnessUp"; } + XF86MonBrightnessDown allow-when-locked=true { spawn "qs" "ipc" "call" "brightness" "brightnessDown"; } // Window management Mod+Tab repeat=false { toggle-overview; } diff --git a/config/niri/.config/niri/config/execs.kdl b/config/niri/.config/niri/config/execs.kdl index 88bfc48..afbb1ad 100644 --- a/config/niri/.config/niri/config/execs.kdl +++ b/config/niri/.config/niri/config/execs.kdl @@ -1,9 +1,6 @@ // Switch configs spawn-at-startup "config-switch" "niri" -// Wallpaper -spawn-at-startup "wallpaper-daemon" - // Not necessary maybe ... spawn-at-startup "fcitx5" @@ -23,7 +20,7 @@ spawn-at-startup "wl-paste" "--type" "image" "--watch" "cliphist" "store" spawn-at-startup "solaar" "-w" "hide" // Some other heavy apps -spawn-at-startup "sunshine" +// spawn-at-startup "sunshine" // spawn-at-startup "spotify" // spawn-at-startup "thunderbird" diff --git a/config/niri/.config/niri/config/styles.kdl b/config/niri/.config/niri/config/styles.kdl index fc86629..5e4f03f 100644 --- a/config/niri/.config/niri/config/styles.kdl +++ b/config/niri/.config/niri/config/styles.kdl @@ -60,7 +60,7 @@ animations { } layer-rule { - match namespace="^swww-daemonbackdrop$" + match namespace="backdrop$" place-within-backdrop true } diff --git a/config/nwg-look/.config/gtk-3.0/bookmarks b/config/nwg-look/.config/gtk-3.0/bookmarks index 8415b45..6c40a30 100644 --- a/config/nwg-look/.config/gtk-3.0/bookmarks +++ b/config/nwg-look/.config/gtk-3.0/bookmarks @@ -1,2 +1 @@ file:///home/kolkas/Desktop -file:///home/kolkas/Nextcloud diff --git a/config/quickshell/.config/quickshell/Assets/Images/Avatar.jpg b/config/quickshell/.config/quickshell/Assets/Avatar.jpg similarity index 100% rename from config/quickshell/.config/quickshell/Assets/Images/Avatar.jpg rename to config/quickshell/.config/quickshell/Assets/Avatar.jpg diff --git a/config/quickshell/.config/quickshell/Assets/Cache/.gitignore b/config/quickshell/.config/quickshell/Assets/Cache/.gitignore new file mode 100644 index 0000000..3b524ba --- /dev/null +++ b/config/quickshell/.config/quickshell/Assets/Cache/.gitignore @@ -0,0 +1,7 @@ +ip.json +spotify-lyrics-offset.txt +notifications.json +images +location.json +network.json +shell-state.json diff --git a/config/quickshell/.config/quickshell/Assets/Config/.gitignore b/config/quickshell/.config/quickshell/Assets/Config/.gitignore index f287df1..e38da20 100644 --- a/config/quickshell/.config/quickshell/Assets/Config/.gitignore +++ b/config/quickshell/.config/quickshell/Assets/Config/.gitignore @@ -1,3 +1 @@ -# some sensitive files -GeoInfoToken.txt -IpAliases.json \ No newline at end of file +settings.json diff --git a/config/quickshell/.config/quickshell/Assets/Config/LyricsOffset.txt b/config/quickshell/.config/quickshell/Assets/Config/LyricsOffset.txt deleted file mode 100644 index c227083..0000000 --- a/config/quickshell/.config/quickshell/Assets/Config/LyricsOffset.txt +++ /dev/null @@ -1 +0,0 @@ -0 \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Assets/Config/Settings.json b/config/quickshell/.config/quickshell/Assets/Config/Settings.json deleted file mode 100644 index 58fd139..0000000 --- a/config/quickshell/.config/quickshell/Assets/Config/Settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "location": "Munich", - "notifications": { - "doNotDisturb": false - }, - "primaryColor": "#89b4fa", - "showLyricsBar": false, - "sunsetDefaultEnabled": true, - "wifiEnabled": true -} diff --git a/config/quickshell/.config/quickshell/Assets/Config/colors.json b/config/quickshell/.config/quickshell/Assets/Config/colors.json new file mode 100644 index 0000000..e4c61cc --- /dev/null +++ b/config/quickshell/.config/quickshell/Assets/Config/colors.json @@ -0,0 +1,5 @@ +{ + "colors": { + "mPrimary": "#89b4fa" + } +} diff --git a/config/quickshell/.config/quickshell/Assets/Fonts/tabler/tabler-icons.ttf b/config/quickshell/.config/quickshell/Assets/Fonts/tabler/noctalia-tabler-icons.ttf similarity index 92% rename from config/quickshell/.config/quickshell/Assets/Fonts/tabler/tabler-icons.ttf rename to config/quickshell/.config/quickshell/Assets/Fonts/tabler/noctalia-tabler-icons.ttf index acc574e..ee8b20f 100644 Binary files a/config/quickshell/.config/quickshell/Assets/Fonts/tabler/tabler-icons.ttf and b/config/quickshell/.config/quickshell/Assets/Fonts/tabler/noctalia-tabler-icons.ttf differ diff --git a/config/quickshell/.config/quickshell/Components/NScrollView.qml b/config/quickshell/.config/quickshell/Components/NScrollView.qml new file mode 100644 index 0000000..f268785 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/NScrollView.qml @@ -0,0 +1,216 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Templates as T +import qs.Constants + +ScrollView { + id: root + + property color handleColor: Qt.alpha(Colors.mHover, 0.8) + property color handleHoverColor: handleColor + property color handlePressedColor: handleColor + property color trackColor: "transparent" + property real handleWidth: Math.round(6 * Style.uiScaleRatio) + property real handleRadius: Style.radiusM + property int verticalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AsNeeded + property bool preventHorizontalScroll: horizontalPolicy === ScrollBar.AlwaysOff + property int boundsBehavior: Flickable.StopAtBounds + readonly property bool verticalScrollable: (contentItem.contentHeight > contentItem.height) || (verticalPolicy == ScrollBar.AlwaysOn) + readonly property bool horizontalScrollable: (contentItem.contentWidth > contentItem.width) || (horizontalPolicy == ScrollBar.AlwaysOn) + property bool showGradientMasks: true + property color gradientColor: Colors.mSurfaceVariant + property int gradientHeight: 16 + property bool reserveScrollbarSpace: true + property real userRightPadding: 0 + + // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster) + property real wheelScrollMultiplier: 2.0 + + rightPadding: userRightPadding + (reserveScrollbarSpace && verticalScrollable ? handleWidth + Style.marginXS : 0) + + implicitWidth: Math.max(implicitBackgroundWidth + leftInset + rightInset, contentWidth + leftPadding + rightPadding) + implicitHeight: Math.max(implicitBackgroundHeight + topInset + bottomInset, contentHeight + topPadding + bottomPadding) + + // Configure the internal flickable when it becomes available + Component.onCompleted: { + configureFlickable(); + createGradients(); + } + + // Dynamically create gradient overlays to avoid interfering with ScrollView content management + function createGradients() { + if (!showGradientMasks) + return; + + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: root.leftPadding + y: root.topPadding + width: root.availableWidth + height: root.gradientHeight + z: 1 + visible: root.showGradientMasks && root.verticalScrollable + opacity: root.contentItem.contentY <= 1 ? 0 : 1 + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: root.gradientColor } + GradientStop { position: 1.0; color: "transparent" } + } + } + `, root, "topGradient"); + + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: root.leftPadding + y: root.height - root.bottomPadding - height + 1 + width: root.availableWidth + height: root.gradientHeight + 1 + z: 1 + visible: root.showGradientMasks && root.verticalScrollable + opacity: (root.contentItem.contentY + root.contentItem.height >= root.contentItem.contentHeight - 1) ? 0 : 1 + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: root.gradientColor } + } + } + `, root, "bottomGradient"); + } + + // Reference to the internal Flickable for wheel handling + property Flickable _internalFlickable: null + + // Function to configure the underlying Flickable + function configureFlickable() { + // Find the internal Flickable (it's usually the first child) + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child.toString().indexOf("Flickable") !== -1) { + // Configure the flickable to prevent horizontal scrolling + child.boundsBehavior = root.boundsBehavior; + root._internalFlickable = child; + + if (root.preventHorizontalScroll) { + child.flickableDirection = Flickable.VerticalFlick; + child.contentWidth = Qt.binding(() => child.width); + } + break; + } + } + } + + WheelHandler { + enabled: root.wheelScrollMultiplier !== 1.0 && root._internalFlickable !== null + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => { + if (!root._internalFlickable) + return; + const flickable = root._internalFlickable; + const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2; + const newY = flickable.contentY - (delta * root.wheelScrollMultiplier); + flickable.contentY = Math.max(0, Math.min(newY, flickable.contentHeight - flickable.height)); + event.accepted = true; + } + } + + // Watch for changes in horizontalPolicy + onHorizontalPolicyChanged: { + preventHorizontalScroll = (horizontalPolicy === ScrollBar.AlwaysOff); + configureFlickable(); + } + + ScrollBar.vertical: ScrollBar { + parent: root + x: root.mirrored ? 0 : root.width - width + y: root.topPadding + height: root.availableHeight + policy: root.verticalPolicy + interactive: root.verticalScrollable + + contentItem: Rectangle { + implicitWidth: root.handleWidth + implicitHeight: 100 + radius: root.handleRadius + color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.verticalScrollable ? (parent.active ? 1.0 : 0.0) : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + background: Rectangle { + implicitWidth: root.handleWidth + implicitHeight: 100 + color: root.trackColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollable ? (parent.active ? 0.3 : 0.0) : 0.0 + radius: root.handleRadius / 2 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } + + ScrollBar.horizontal: ScrollBar { + parent: root + x: root.leftPadding + y: root.height - height + width: root.availableWidth + policy: root.horizontalPolicy + interactive: root.horizontalScrollable + + contentItem: Rectangle { + implicitWidth: 100 + implicitHeight: root.handleWidth + radius: root.handleRadius + color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 1.0 : root.horizontalScrollable ? (parent.active ? 1.0 : 0.0) : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + } + + background: Rectangle { + implicitWidth: 100 + implicitHeight: root.handleWidth + color: root.trackColor + opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.horizontalScrollable ? (parent.active ? 0.3 : 0.0) : 0.0 + radius: root.handleRadius / 2 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationFast + } + } + } + } +} diff --git a/config/quickshell/.config/quickshell/Components/UBox.qml b/config/quickshell/.config/quickshell/Components/UBox.qml new file mode 100644 index 0000000..e341e3f --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UBox.qml @@ -0,0 +1,29 @@ +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import qs.Components +import qs.Constants + +// Rounded group container using the variant surface color. +// To be used in side panels and settings panes to group fields or buttons. +Rectangle { + id: root + + property bool compact: false + + color: compact ? Colors.transparent : Colors.mSurfaceVariant + radius: Style.radiusM + layer.enabled: !compact + + layer.effect: MultiEffect { + shadowEnabled: true + blurMax: Style.shadowBlurMax + shadowBlur: Style.shadowBlur + shadowOpacity: Style.shadowOpacity + shadowColor: Colors.mShadow + shadowHorizontalOffset: Style.shadowHorizontalOffset + shadowVerticalOffset: Style.shadowVerticalOffset + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NBusyIndicator.qml b/config/quickshell/.config/quickshell/Components/UBusyIndicator.qml similarity index 95% rename from config/quickshell/.config/quickshell/Noctalia/NBusyIndicator.qml rename to config/quickshell/.config/quickshell/Components/UBusyIndicator.qml index 137f37b..353e3c7 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NBusyIndicator.qml +++ b/config/quickshell/.config/quickshell/Components/UBusyIndicator.qml @@ -1,12 +1,11 @@ import QtQuick import qs.Constants -import qs.Noctalia Item { id: root property bool running: true - property color color: Color.mPrimary + property color color: Colors.mPrimary property int size: Style.baseWidgetSize property int strokeWidth: Style.borderL property int duration: Style.animationSlow * 2 diff --git a/config/quickshell/.config/quickshell/Components/UButton.qml b/config/quickshell/.config/quickshell/Components/UButton.qml new file mode 100644 index 0000000..acd8bc8 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UButton.qml @@ -0,0 +1,133 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Components +import qs.Constants + +Rectangle { + id: root + + // Public properties + property string text: "" + property string icon: "" + property string tooltipText + property color backgroundColor: Colors.mPrimary + property color textColor: Colors.mOnPrimary + property color hoverColor: Colors.mHover + property color textHoverColor: Colors.mOnHover + property real fontSize: Style.fontSizeM + property int fontWeight: Style.fontWeightSemiBold + property real iconSize: Style.fontSizeL + property bool outlined: false + property int horizontalAlignment: Qt.AlignHCenter + property real buttonRadius: Style.radiusS + // Internal properties + property bool hovered: false + readonly property color contentColor: { + if (!root.enabled) + return Colors.mOnSurfaceVariant; + + if (root.hovered) + return root.textHoverColor; + + if (root.outlined) + return root.backgroundColor; + + return root.textColor; + } + + // Signals + signal clicked() + signal rightClicked() + signal middleClicked() + signal entered() + signal exited() + + // Dimensions + implicitWidth: contentRow.implicitWidth + (fontSize * 2) + implicitHeight: contentRow.implicitHeight + (fontSize) + // Appearance + radius: root.buttonRadius + color: { + if (!root.enabled) + return outlined ? "transparent" : Qt.lighter(Colors.mSurfaceVariant, 1.2); + + if (root.hovered) + return hoverColor; + + return root.outlined ? "transparent" : root.backgroundColor; + } + border.width: outlined ? Style.borderS : 0 + border.color: { + if (!root.enabled) + return Colors.mOutline; + + if (root.hovered) + return hoverColor; + + return root.outlined ? root.backgroundColor : "transparent"; + } + opacity: enabled ? 1 : 0.6 + + // Content + RowLayout { + id: contentRow + + anchors.verticalCenter: parent.verticalCenter + anchors.left: root.horizontalAlignment === Qt.AlignLeft ? parent.left : undefined + anchors.horizontalCenter: root.horizontalAlignment === Qt.AlignHCenter ? parent.horizontalCenter : undefined + anchors.leftMargin: root.horizontalAlignment === Qt.AlignLeft ? Style.marginL : 0 + spacing: Style.marginXS + + // Icon (optional) + UIcon { + Layout.alignment: Qt.AlignVCenter + visible: root.icon !== "" + iconName: root.icon + iconSize: root.iconSize + color: contentColor + } + + // Text + UText { + Layout.alignment: Qt.AlignVCenter + visible: root.text !== "" + text: root.text + pointSize: root.fontSize + font.weight: root.fontWeight + color: contentColor + } + + } + + // Mouse interaction + MouseArea { + id: mouseArea + + anchors.fill: parent + enabled: root.enabled + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onEntered: { + root.hovered = true; + root.entered(); + } + onExited: { + root.hovered = false; + root.exited(); + } + onPressed: (mouse) => { + if (mouse.button === Qt.LeftButton) + root.clicked(); + else if (mouse.button == Qt.RightButton) + root.rightClicked(); + else if (mouse.button == Qt.MiddleButton) + root.middleClicked(); + } + onCanceled: { + root.hovered = false; + } + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UClock.qml b/config/quickshell/.config/quickshell/Components/UClock.qml new file mode 100644 index 0000000..2b011bf --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UClock.qml @@ -0,0 +1,427 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Utils + +Item { + id: root + + property var now: Time.now + // Style: "analog" or "digital" + property string clockStyle: "analog" + // Show seconds progress ring (digital only) + property bool showProgress: true + // Colors properties + property color backgroundColor: Colors.mPrimary + property color clockColor: Colors.mOnPrimary + property color secondHandColor: Colors.mError + property color progressColor: root.secondHandColor + // Font size properties for digital clock + property real hoursFontSize: Style.fontSizeXS + property real minutesFontSize: Style.fontSizeXXS + property int hoursFontWeight: Style.fontWeightBold + property int minutesFontWeight: Style.fontWeightBold + // Scale ratio for canvas line widths (used by desktop widget scaling) + property real scaleRatio: 1 + + height: Math.round((Style.fontSizeXXXL * 1.9) / 2) * 2 + width: root.height + + Loader { + id: clockLoader + + anchors.fill: parent + sourceComponent: { + if (root.clockStyle === "analog") + return analogClockComponent; + + if (root.clockStyle === "binary") + return binaryClockComponent; + + return digitalClockComponent; + } + onLoaded: { + item.now = Qt.binding(function() { + return root.now; + }); + item.backgroundColor = Qt.binding(function() { + return root.backgroundColor; + }); + item.clockColor = Qt.binding(function() { + return root.clockColor; + }); + if (item.hasOwnProperty("secondHandColor")) + item.secondHandColor = Qt.binding(function() { + return root.secondHandColor; + }); + + if (item.hasOwnProperty("progressColor")) + item.progressColor = Qt.binding(function() { + return root.progressColor; + }); + + if (item.hasOwnProperty("hoursFontSize")) + item.hoursFontSize = Qt.binding(function() { + return root.hoursFontSize; + }); + + if (item.hasOwnProperty("minutesFontSize")) + item.minutesFontSize = Qt.binding(function() { + return root.minutesFontSize; + }); + + if ("hoursFontWeight" in item) + item.hoursFontWeight = Qt.binding(function() { + return root.hoursFontWeight; + }); + + if ("minutesFontWeight" in item) + item.minutesFontWeight = Qt.binding(function() { + return root.minutesFontWeight; + }); + + if (item.hasOwnProperty("scaleRatio")) + item.scaleRatio = Qt.binding(function() { + return root.scaleRatio; + }); + + if ("showProgress" in item) + item.showProgress = Qt.binding(function() { + return root.showProgress; + }); + + } + } + + Component { + id: analogClockComponent + + UClockAnalog { + } + + } + + Component { + id: digitalClockComponent + + UClockDigital { + } + + } + + Component { + id: binaryClockComponent + + UClockBinary { + } + + } + + // Analog Clock Component + component UClockAnalog: Item { + property var now + property color backgroundColor: Colors.mPrimary + property color clockColor: Colors.mOnPrimary + property color secondHandColor: Colors.mError + property real scaleRatio: 1 + + anchors.fill: parent + + Canvas { + id: clockCanvas + + anchors.fill: parent + onPaint: { + var currentTime = Time.now; + var hours = currentTime.getHours(); + var minutes = currentTime.getMinutes(); + var seconds = currentTime.getSeconds(); + const markAlpha = 0.7; + var ctx = getContext("2d"); + ctx.reset(); + ctx.translate(width / 2, height / 2); + var radius = Math.min(width, height) / 2; + // Hour marks + ctx.strokeStyle = Qt.alpha(clockColor, markAlpha); + ctx.lineWidth = 2 * scaleRatio; + var scaleFactor = 0.7; + for (var i = 0; i < 12; i++) { + var scaleFactor = 0.8; + if (i % 3 === 0) + scaleFactor = 0.65; + + ctx.save(); + ctx.rotate(i * Math.PI / 6); + ctx.beginPath(); + ctx.moveTo(0, -radius * scaleFactor); + ctx.lineTo(0, -radius); + ctx.stroke(); + ctx.restore(); + } + // Hour hand + ctx.save(); + var hourAngle = (hours % 12 + minutes / 60) * Math.PI / 6; + ctx.rotate(hourAngle); + ctx.strokeStyle = clockColor; + ctx.lineWidth = 3 * scaleRatio; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, -radius * 0.6); + ctx.stroke(); + ctx.restore(); + // Minute hand + ctx.save(); + var minuteAngle = (minutes + seconds / 60) * Math.PI / 30; + ctx.rotate(minuteAngle); + ctx.strokeStyle = clockColor; + ctx.lineWidth = 2 * scaleRatio; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, -radius * 0.9); + ctx.stroke(); + ctx.restore(); + // Second hand + ctx.save(); + var secondAngle = seconds * Math.PI / 30; + ctx.rotate(secondAngle); + ctx.strokeStyle = secondHandColor; + ctx.lineWidth = 1.6 * scaleRatio; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(0, -radius); + ctx.stroke(); + ctx.restore(); + // Center dot + ctx.beginPath(); + ctx.arc(0, 0, 3 * scaleRatio, 0, 2 * Math.PI); + ctx.fillStyle = clockColor; + ctx.fill(); + } + Component.onCompleted: requestPaint() + + Connections { + function onNowChanged() { + clockCanvas.requestPaint(); + } + + target: Time + } + + } + + } + + // Digital Clock Component + component UClockDigital: Item { + property var now + property color backgroundColor: Colors.mPrimary + property color clockColor: Colors.mOnPrimary + property color progressColor: Colors.mError + property real hoursFontSize: Style.fontSizeXS + property real minutesFontSize: Style.fontSizeXXS + property int hoursFontWeight: Style.fontWeightBold + property int minutesFontWeight: Style.fontWeightBold + property real scaleRatio: 1 + property bool showProgress: true + + anchors.fill: parent + + // Digital clock's seconds circular progress + Canvas { + id: secondsProgress + + property real progress: now.getSeconds() / 60 + + anchors.fill: parent + visible: showProgress + onProgressChanged: requestPaint() + onPaint: { + var ctx = getContext("2d"); + var centerX = width / 2; + var centerY = height / 2; + var radius = Math.min(width, height) / 2 - 3 * scaleRatio; + ctx.reset(); + // Background circle + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); + ctx.lineWidth = 2.5 * scaleRatio; + ctx.strokeStyle = Qt.alpha(clockColor, 0.15); + ctx.stroke(); + // Progress arc + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI); + ctx.lineWidth = 2.5 * scaleRatio; + ctx.strokeStyle = progressColor; + ctx.lineCap = "round"; + ctx.stroke(); + } + + Connections { + function onNowChanged() { + const total = now.getSeconds() * 1000 + now.getMilliseconds(); + secondsProgress.progress = total / 60000; + } + + target: Time + } + + } + + // Digital clock + ColumnLayout { + anchors.centerIn: parent + spacing: -Style.marginXXS + + UText { + text: Qt.formatTime(now, "HH") + pointSize: hoursFontSize + font.weight: hoursFontWeight + color: clockColor + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: Qt.formatTime(now, "mm") + pointSize: minutesFontSize + font.weight: minutesFontWeight + color: clockColor + Layout.alignment: Qt.AlignHCenter + } + + } + + } + + // Binary Clock Component + component UClockBinary: Item { + // BCD (Binary Coded Decimal) Format: + // H1 H2 : M1 M2 : S1 S2 + // H1 (tens): 0-2 (2 bits) + // H2 (ones): 0-9 (4 bits) + // M1 (tens): 0-5 (3 bits) + // M2 (ones): 0-9 (4 bits) + // S1 (tens): 0-5 (3 bits) + // S2 (ones): 0-9 (4 bits) + + property var now + property color backgroundColor + property color clockColor: Colors.mOnPrimary + readonly property int h: now.getHours() + readonly property int m: now.getMinutes() + readonly property int s: now.getSeconds() + + anchors.fill: parent + + RowLayout { + anchors.centerIn: parent + spacing: parent.width * 0.05 + + // Hours + RowLayout { + spacing: parent.parent.width * 0.02 + + BinaryColumn { + value: Math.floor(h / 10) + bits: 2 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + BinaryColumn { + value: h % 10 + bits: 4 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + } + + // Minutes + RowLayout { + spacing: parent.parent.width * 0.02 + + BinaryColumn { + value: Math.floor(m / 10) + bits: 3 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + BinaryColumn { + value: m % 10 + bits: 4 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + } + + // Seconds + RowLayout { + spacing: parent.parent.width * 0.02 + + BinaryColumn { + value: Math.floor(s / 10) + bits: 3 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + BinaryColumn { + value: s % 10 + bits: 4 + dotSize: root.width * 0.08 + activeColor: clockColor + Layout.alignment: Qt.AlignBottom + } + + } + + } + + } + + component BinaryColumn: Column { + property int value: 0 + property int bits: 4 + property real dotSize: 10 + property color activeColor: "white" + + spacing: dotSize * 0.4 + + Repeater { + model: bits + + Rectangle { + property int bitIndex: (bits - 1) - index + property bool isActive: (value >> bitIndex) & 1 + + width: dotSize + height: dotSize + radius: dotSize / 2 + color: isActive ? activeColor : Qt.alpha(activeColor, 0.2) + + Behavior on color { + ColorAnimation { + duration: 200 + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NContextMenu.qml b/config/quickshell/.config/quickshell/Components/UContextMenu.qml similarity index 55% rename from config/quickshell/.config/quickshell/Noctalia/NContextMenu.qml rename to config/quickshell/.config/quickshell/Components/UContextMenu.qml index c2d735d..97c912a 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NContextMenu.qml +++ b/config/quickshell/.config/quickshell/Components/UContextMenu.qml @@ -2,16 +2,55 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils +/* +* UContextMenu - Popup-based context menu for use inside panels and dialogs +* +* Use this component when you need a context menu inside: +* - Settings panels +* - Dialogs +* - Repeater delegates +* - Any nested component context +* +* For bar widgets and top-level window contexts, use NPopupContextMenu instead, +* which provides better screen boundary handling and compositor integration. +* +* Usage: +* UContextMenu { +* id: contextMenu +* parent: Overlay.overlay +* model: [ +* { "label": "Action 1", "action": "action1", "icon": "icon-name" }, +* { "label": "Action 2", "action": "action2" } +* ] +* onTriggered: action => { Logger.i("MyModule", "Selected:", action) } +* } +* +* MouseArea { +* onClicked: contextMenu.openAtItem(parent, mouse.x, mouse.y) +* } +*/ Popup { id: root - property alias model: listView.model + property var model: [] property real itemHeight: 36 property real itemPadding: Style.marginM + property int verticalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AsNeeded + // Filter out hidden items to avoid spacing artifacts from zero-height items + readonly property var filteredModel: { + if (!model || model.length === 0) + return []; + + var filtered = []; + for (var i = 0; i < model.length; i++) { + if (model[i].visible !== false) + filtered.push(model[i]); + + } + return filtered; + } signal triggered(string action) @@ -30,22 +69,24 @@ Popup { width: 180 padding: Style.marginS - onOpened: PanelService.willOpenPopup(root) - onClosed: PanelService.willClosePopup(root) background: Rectangle { - color: Color.mSurfaceVariant - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) + color: Colors.mSurfaceVariant + border.color: Colors.mOutline + border.width: Style.borderS radius: Style.radiusM } - contentItem: NListView { + contentItem: UListView { id: listView - implicitHeight: contentHeight + implicitHeight: Math.max(contentHeight, root.itemHeight) spacing: Style.marginXXS interactive: contentHeight > root.height + verticalPolicy: root.verticalPolicy + horizontalPolicy: root.horizontalPolicy + reserveScrollbarSpace: false + model: root.filteredModel delegate: ItemDelegate { id: menuItem @@ -53,9 +94,8 @@ Popup { // Store reference to the popup property var popup: root - width: listView.width - height: modelData.visible !== false ? root.itemHeight : 0 - visible: modelData.visible !== false + width: listView.availableWidth + height: root.itemHeight opacity: modelData.enabled !== false ? 1 : 0.5 enabled: modelData.enabled !== false onClicked: { @@ -66,27 +106,19 @@ Popup { } background: Rectangle { - color: menuItem.hovered && menuItem.enabled ? Color.mTertiary : Color.transparent + color: menuItem.hovered && menuItem.enabled ? Colors.mHover : "transparent" radius: Style.radiusS - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - } contentItem: RowLayout { spacing: Style.marginS // Optional icon - NIcon { + UIcon { visible: modelData.icon !== undefined - icon: modelData.icon || "" - pointSize: Style.fontSizeM - color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface + iconName: modelData.icon || "" + iconSize: Style.fontSizeM + color: menuItem.hovered && menuItem.enabled ? Colors.mOnHover : Colors.mOnSurface Layout.leftMargin: root.itemPadding Behavior on color { @@ -98,10 +130,10 @@ Popup { } - NText { + UText { text: modelData.label || modelData.text || "" pointSize: Style.fontSizeM - color: menuItem.hovered && menuItem.enabled ? Color.mOnTertiary : Color.mOnSurface + color: menuItem.hovered && menuItem.enabled ? Colors.mOnHover : Colors.mOnSurface verticalAlignment: Text.AlignVCenter Layout.fillWidth: true Layout.leftMargin: modelData.icon === undefined ? root.itemPadding : 0 diff --git a/config/quickshell/.config/quickshell/Noctalia/NDivider.qml b/config/quickshell/.config/quickshell/Components/UDivider.qml similarity index 76% rename from config/quickshell/.config/quickshell/Noctalia/NDivider.qml rename to config/quickshell/.config/quickshell/Components/UDivider.qml index d476143..65a94a9 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NDivider.qml +++ b/config/quickshell/.config/quickshell/Components/UDivider.qml @@ -12,22 +12,22 @@ Rectangle { GradientStop { position: 0 - color: Color.transparent + color: Colors.transparent } GradientStop { position: 0.1 - color: Color.mOutline + color: Colors.mOutline } GradientStop { position: 0.9 - color: Color.mOutline + color: Colors.mOutline } GradientStop { position: 1 - color: Color.transparent + color: Colors.transparent } } diff --git a/config/quickshell/.config/quickshell/Components/UDropShadow.qml b/config/quickshell/.config/quickshell/Components/UDropShadow.qml new file mode 100644 index 0000000..12b9ebc --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UDropShadow.qml @@ -0,0 +1,31 @@ +import QtQuick +import QtQuick.Effects +import qs.Constants + +// Unified shadow system +Item { + id: root + + required property var source + property bool autoPaddingEnabled: false + property real shadowHorizontalOffset: Style.shadowHorizontalOffset + property real shadowVerticalOffset: Style.shadowVerticalOffset + property real shadowOpacity: Style.shadowOpacity + property color shadowColor: Colors.mShadow + property real shadowBlur: Style.shadowBlur + + layer.enabled: true + + layer.effect: MultiEffect { + source: root.source + shadowEnabled: true + blurMax: Style.shadowBlurMax + shadowBlur: root.shadowBlur + shadowOpacity: root.shadowOpacity + shadowColor: root.shadowColor + shadowHorizontalOffset: root.shadowHorizontalOffset + shadowVerticalOffset: root.shadowVerticalOffset + autoPaddingEnabled: root.autoPaddingEnabled + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UIcon.qml b/config/quickshell/.config/quickshell/Components/UIcon.qml new file mode 100644 index 0000000..16e94f8 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UIcon.qml @@ -0,0 +1,19 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants + +Text { + id: root + + property string iconName: "" + property string textOverride: "" + property string fontFamily: Fonts.icon + property int iconSize: Style.fontSizeL + + color: Colors.mPrimary + text: textOverride ? textOverride : Icons.get(iconName) || Icons.get(Icons.defaultIcon) + font.family: fontFamily + font.pointSize: iconSize + font.bold: false +} diff --git a/config/quickshell/.config/quickshell/Components/UIconButton.qml b/config/quickshell/.config/quickshell/Components/UIconButton.qml new file mode 100644 index 0000000..ee8013e --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UIconButton.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Components +import qs.Constants + +Item { + id: root + + property alias iconName: icon.iconName + property alias textOverride: icon.textOverride + property alias fontFamily: icon.fontFamily + property int baseSize: Style.fontSizeXXXL + property alias iconSize: icon.iconSize + property color colorFg: Colors.mPrimary + property color colorBg: Colors.transparent + property color colorFgHover: Colors.mOnPrimary + property color colorBgHover: colorFg + readonly property bool hovered: alwaysHover || mouseArea.containsMouse + property real radius: Style.radiusS + property bool disabledHover: false + property bool alwaysHover: false + + signal entered() + signal exited() + signal clicked() + signal rightClicked() + signal middleClicked() + + implicitWidth: baseSize + implicitHeight: baseSize + + MouseArea { + id: mouseArea + + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: !disabledHover + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + onEntered: root.entered() + onExited: root.exited() + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) + root.rightClicked(); + else if (mouse.button === Qt.MiddleButton) + root.middleClicked(); + else if (mouse.button === Qt.LeftButton) + root.clicked(); + } + } + + Rectangle { + anchors.fill: parent + color: root.hovered ? colorBgHover : colorBg + radius: root.radius + + Behavior on color { + ColorAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutCubic + } + + } + + } + + UIcon { + id: icon + + color: root.hovered ? colorFgHover : colorFg + anchors.centerIn: parent + + Behavior on color { + ColorAnimation { + duration: Style.animationNormal + easing.type: Easing.InOutCubic + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UImageRounded.qml b/config/quickshell/.config/quickshell/Components/UImageRounded.qml new file mode 100644 index 0000000..e5e509d --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UImageRounded.qml @@ -0,0 +1,69 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import qs.Constants + +Item { + id: root + + property real radius: 0 + property string imagePath: "" + property string fallbackIcon: "" + property real fallbackIconSize: Style.fontSizeXXL + property real borderWidth: 0 + property color borderColor: "transparent" + property int imageFillMode: Image.PreserveAspectCrop + readonly property bool showFallback: (fallbackIcon !== undefined && fallbackIcon !== "") && (imagePath === undefined || imagePath === "" || imageSource.status === Image.Error) + readonly property int status: imageSource.status + + Rectangle { + anchors.fill: parent + radius: root.radius + color: "transparent" + border.width: root.borderWidth + border.color: root.borderColor + + Image { + id: imageSource + + anchors.fill: parent + anchors.margins: root.borderWidth + visible: false + source: root.imagePath + mipmap: true + smooth: true + asynchronous: true + antialiasing: true + fillMode: root.imageFillMode + } + + ShaderEffect { + property variant source: imageSource + property real itemWidth: width + property real itemHeight: height + property real sourceWidth: imageSource.sourceSize.width + property real sourceHeight: imageSource.sourceSize.height + property real cornerRadius: Math.max(0, root.radius - root.borderWidth) + property real imageOpacity: 1 + property int fillMode: root.imageFillMode + + anchors.fill: parent + anchors.margins: root.borderWidth + visible: !root.showFallback + fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/rounded_image.frag.qsb") + supportsAtlasTextures: false + blending: true + } + + UIcon { + anchors.fill: parent + anchors.margins: root.borderWidth + visible: root.showFallback + iconName: root.fallbackIcon + iconSize: root.fallbackIconSize + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Components/ULabel.qml b/config/quickshell/.config/quickshell/Components/ULabel.qml new file mode 100644 index 0000000..82034a5 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/ULabel.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Constants + +ColumnLayout { + id: root + + property string label: "" + property string description: "" + property string icon: "" + property color labelColor: Colors.mOnSurface + property color descriptionColor: Colors.mOnSurfaceVariant + property color iconColor: Colors.mOnSurface + property bool showIndicator: false + property string indicatorTooltip: "" + + opacity: enabled ? 1 : 0.6 + spacing: Style.marginXXS + visible: root.label != "" || root.description != "" + Layout.fillWidth: true + + RowLayout { + spacing: Style.marginXS + Layout.fillWidth: true + visible: root.label !== "" + + UIcon { + visible: root.icon !== "" + iconName: root.icon + iconSize: Style.fontSizeXXL + color: root.iconColor + Layout.rightMargin: Style.marginS + } + + UText { + Layout.fillWidth: !root.showIndicator + text: root.label + pointSize: Style.fontSizeL + font.weight: Style.fontWeightSemiBold + color: labelColor + wrapMode: Text.WordWrap + } + + } + + UText { + visible: root.description !== "" + Layout.fillWidth: true + text: root.description + pointSize: Style.fontSizeS + color: root.descriptionColor + wrapMode: Text.WordWrap + textFormat: Text.StyledText + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NListView.qml b/config/quickshell/.config/quickshell/Components/UListView.qml similarity index 53% rename from config/quickshell/.config/quickshell/Noctalia/NListView.qml rename to config/quickshell/.config/quickshell/Components/UListView.qml index 2d79633..a9f3f74 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NListView.qml +++ b/config/quickshell/.config/quickshell/Components/UListView.qml @@ -2,19 +2,31 @@ import QtQuick import QtQuick.Controls import QtQuick.Templates as T import qs.Constants -import qs.Noctalia Item { id: root - property color handleColor: Qt.alpha(Color.mTertiary, 0.8) + property color handleColor: Qt.alpha(Colors.mHover, 0.8) property color handleHoverColor: handleColor property color handlePressedColor: handleColor - property color trackColor: Color.transparent + property color trackColor: "transparent" property real handleWidth: 6 property real handleRadius: Style.radiusM property int verticalPolicy: ScrollBar.AsNeeded - property int horizontalPolicy: ScrollBar.AsNeeded + property int horizontalPolicy: ScrollBar.AlwaysOff + readonly property bool verticalScrollBarActive: { + if (listView.ScrollBar.vertical.policy === ScrollBar.AlwaysOff) + return false; + + return listView.contentHeight > listView.height; + } + readonly property bool contentOverflows: listView.contentHeight > listView.height + property bool showGradientMasks: true + property color gradientColor: Colors.mSurfaceVariant + property int gradientHeight: 16 + property bool reserveScrollbarSpace: true + // Available width for content (excludes scrollbar space when reserveScrollbarSpace is true) + readonly property real availableWidth: width - (reserveScrollbarSpace ? handleWidth + Style.marginXS : 0) // Forward ListView properties property alias model: listView.model property alias delegate: listView.delegate @@ -53,6 +65,8 @@ Item { property alias dragging: listView.dragging property alias horizontalVelocity: listView.horizontalVelocity property alias verticalVelocity: listView.verticalVelocity + // Scroll speed multiplier for mouse wheel (1.0 = default, higher = faster) + property real wheelScrollMultiplier: 2 // Forward ListView methods function positionViewAtIndex(index, mode) { @@ -99,84 +113,110 @@ Item { return listView.itemAtIndex(index); } + // Dynamically create gradient overlays + function createGradients() { + if (!showGradientMasks) + return ; + + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: 0 + y: 0 + width: root.availableWidth + height: root.gradientHeight + z: 1 + visible: root.showGradientMasks && root.contentOverflows + opacity: { + if (listView.contentY <= 1) return 0; + if (listView.currentItem && listView.currentItem.y - listView.contentY < root.gradientHeight) return 0; + return 1; + } + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: root.gradientColor } + GradientStop { position: 1.0; color: "transparent" } + } + } + `, root, "topGradient"); + Qt.createQmlObject(` + import QtQuick + import qs.Constants + Rectangle { + x: 0 + anchors.bottom: parent.bottom + anchors.bottomMargin: -1 + width: root.availableWidth + height: root.gradientHeight + 1 + z: 1 + visible: root.showGradientMasks && root.contentOverflows + opacity: { + if (listView.contentY + listView.height >= listView.contentHeight - 1) return 0; + if (listView.currentItem && listView.currentItem.y + listView.currentItem.height > listView.contentY + listView.height - root.gradientHeight) return 0; + return 1; + } + Behavior on opacity { + NumberAnimation { duration: Style.animationFast; easing.type: Easing.InOutQuad } + } + gradient: Gradient { + GradientStop { position: 0.0; color: "transparent" } + GradientStop { position: 1.0; color: root.gradientColor } + } + } + `, root, "bottomGradient"); + } + // Set reasonable implicit sizes for Layout usage implicitWidth: 200 implicitHeight: 200 + Component.onCompleted: { + createGradients(); + } ListView { id: listView anchors.fill: parent - // Enable clipping to keep content within bounds + anchors.rightMargin: root.reserveScrollbarSpace ? root.handleWidth + Style.marginXS : 0 clip: true - // Enable flickable for smooth scrolling boundsBehavior: Flickable.StopAtBounds - ScrollBar.vertical: ScrollBar { - parent: listView - x: listView.mirrored ? 0 : listView.width - width - y: 0 - height: listView.height - active: listView.ScrollBar.horizontal.active - policy: root.verticalPolicy - - contentItem: Rectangle { - implicitWidth: root.handleWidth - implicitHeight: 100 - radius: root.handleRadius - color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1 : 0 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - } - - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - + WheelHandler { + enabled: !root.contentOverflows + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (event) => { + event.accepted = true; } - - background: Rectangle { - implicitWidth: root.handleWidth - implicitHeight: 100 - color: root.trackColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0 - radius: root.handleRadius / 2 - - Behavior on opacity { - NumberAnimation { - duration: Style.animationFast - } - - } - - } - } - ScrollBar.horizontal: ScrollBar { - id: horizontalScrollBar + WheelHandler { + enabled: root.wheelScrollMultiplier !== 1 && root.contentOverflows + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: (event) => { + const delta = event.pixelDelta.y !== 0 ? event.pixelDelta.y : event.angleDelta.y / 2; + const newY = listView.contentY - (delta * root.wheelScrollMultiplier); + listView.contentY = Math.max(0, Math.min(newY, listView.contentHeight - listView.height)); + event.accepted = true; + } + } - parent: listView - x: 0 - y: listView.height - height - width: listView.width - active: listView.ScrollBar.vertical.active - policy: root.horizontalPolicy + ScrollBar.vertical: ScrollBar { + parent: root + x: root.mirrored ? 0 : root.width - width + y: 0 + height: root.height + policy: root.verticalPolicy + visible: policy === ScrollBar.AlwaysOn || root.verticalScrollBarActive contentItem: Rectangle { - implicitWidth: 100 - implicitHeight: root.handleWidth + implicitWidth: root.handleWidth + implicitHeight: 100 radius: root.handleRadius color: parent.pressed ? root.handlePressedColor : parent.hovered ? root.handleHoverColor : root.handleColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 1 : 0 + opacity: parent.policy === ScrollBar.AlwaysOn ? 1 : root.verticalScrollBarActive ? (parent.active ? 1 : 0) : 0 Behavior on opacity { NumberAnimation { @@ -195,10 +235,10 @@ Item { } background: Rectangle { - implicitWidth: 100 - implicitHeight: root.handleWidth + implicitWidth: root.handleWidth + implicitHeight: 100 color: root.trackColor - opacity: parent.policy === ScrollBar.AlwaysOn || parent.active ? 0.3 : 0 + opacity: parent.policy === ScrollBar.AlwaysOn ? 0.3 : root.verticalScrollBarActive ? (parent.active ? 0.3 : 0) : 0 radius: root.handleRadius / 2 Behavior on opacity { diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/MonitorItem.qml b/config/quickshell/.config/quickshell/Components/UProgressExpand.qml similarity index 86% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/MonitorItem.qml rename to config/quickshell/.config/quickshell/Components/UProgressExpand.qml index 405d52e..46fcaee 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/MonitorItem.qml +++ b/config/quickshell/.config/quickshell/Components/UProgressExpand.qml @@ -7,12 +7,12 @@ import qs.Constants Item { id: root - required property string symbol - property int symbolSize: Fonts.icon + required property string iconName + property int iconSize: Style.fontSizeM property real maxValue: 100 property real value: 100 property string textValue: "" // override value in textDisplay if set - property color fillColor: Colors.primary + property color fillColor: Colors.mPrimary property string textSuffix: "" property bool pointerCursor: true property bool expandOnValueChange: false @@ -22,7 +22,7 @@ Item { property bool _isFirst: true property bool disableHover: false property bool critical: false - property color criticalColor: Colors.red + property color criticalColor: Colors.mRed readonly property real ratio: value / maxValue property color realColor: critical ? criticalColor : fillColor @@ -32,8 +32,8 @@ Item { signal rightClicked() signal middleClicked() - implicitHeight: parent.height - 5 - implicitWidth: parent.height + (_expand ? textDisplay.width : 0) + implicitHeight: Math.max(iconSize, textLabel.implicitHeight) + 12 + implicitWidth: height + textDisplay.implicitWidth Loader { id: connectionLoader @@ -95,15 +95,14 @@ Item { } RowLayout { - anchors.top: parent.top - anchors.bottom: parent.bottom + anchors.fill: parent spacing: 0 Item { id: progressDisplay - Layout.preferredHeight: parent.height - Layout.preferredWidth: parent.height + Layout.preferredHeight: root.height + Layout.preferredWidth: root.height Canvas { id: progressCircle @@ -140,16 +139,15 @@ Item { } - Text { - id: symbolText + UIcon { + id: symbolIcon anchors.fill: parent - text: symbol - font.family: Fonts.nerd - font.pointSize: symbolSize - color: root.realColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + iconName: root.iconName + iconSize: root.iconSize + color: root.realColor } } @@ -161,17 +159,15 @@ Item { implicitWidth: root._expand ? textLabel.implicitWidth + 10 : 0 clip: true - Text { + UText { id: textLabel anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 5 text: (textValue || Math.round(root.value)) + root.textSuffix - font.pointSize: Fonts.small - font.family: Fonts.primary + font.pointSize: Style.fontSizeS color: root.realColor - opacity: root._expand ? 1 : 0 } Behavior on implicitWidth { diff --git a/config/quickshell/.config/quickshell/Noctalia/NScrollView.qml b/config/quickshell/.config/quickshell/Components/UScrollView.qml similarity index 97% rename from config/quickshell/.config/quickshell/Noctalia/NScrollView.qml rename to config/quickshell/.config/quickshell/Components/UScrollView.qml index 699371c..9375465 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NScrollView.qml +++ b/config/quickshell/.config/quickshell/Components/UScrollView.qml @@ -6,10 +6,10 @@ import qs.Constants T.ScrollView { id: root - property color handleColor: Qt.alpha(Color.mTertiary, 0.8) + property color handleColor: Qt.alpha(Colors.mPrimary, 0.8) property color handleHoverColor: handleColor property color handlePressedColor: handleColor - property color trackColor: Color.transparent + property color trackColor: Colors.transparent property real handleWidth: 6 property real handleRadius: Style.radiusM property int verticalPolicy: ScrollBar.AsNeeded diff --git a/config/quickshell/.config/quickshell/Components/USlider.qml b/config/quickshell/.config/quickshell/Components/USlider.qml new file mode 100644 index 0000000..c770cd0 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/USlider.qml @@ -0,0 +1,275 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Shapes +import qs.Constants + +Slider { + id: root + + property color fillColor: Colors.mPrimary + property var cutoutColor: Colors.mSurface + property bool snapAlways: true + property real heightRatio: 0.7 + property string tooltipText + property string tooltipDirection: "auto" + property bool hovering: false + readonly property color effectiveFillColor: enabled ? fillColor : Colors.mOutline + readonly property real knobDiameter: Math.round((Style.baseWidgetSize * heightRatio) / 2) * 2 + readonly property real trackHeight: Math.round((knobDiameter * 0.4) / 2) * 2 + readonly property real trackRadius: Math.min(Style.radiusL, trackHeight / 2) + readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1) / 2) * 2 + + padding: cutoutExtra / 2 + snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease + implicitHeight: Math.max(trackHeight, knobDiameter) + + background: Item { + id: bgContainer + + readonly property real fillWidth: root.visualPosition * width + + x: root.leftPadding + y: root.topPadding + Math.round((root.availableHeight - root.trackHeight) / 2) + implicitWidth: Style.sliderWidth + implicitHeight: root.trackHeight + width: root.availableWidth + height: root.trackHeight + + // Background track + Shape { + anchors.fill: parent + visible: bgContainer.width > 0 && bgContainer.height > 0 + preferredRendererType: Shape.CurveRenderer + + ShapePath { + id: bgPath + + readonly property real w: bgContainer.width + readonly property real h: bgContainer.height + readonly property real r: root.trackRadius + + strokeColor: Qt.alpha(Colors.mOutline, 0.5) + strokeWidth: Style.borderS + fillColor: Qt.alpha(Colors.mSurface, 0.5) + startX: r + startY: 0 + + PathLine { + x: bgPath.w - bgPath.r + y: 0 + } + + PathArc { + x: bgPath.w + y: bgPath.r + radiusX: bgPath.r + radiusY: bgPath.r + } + + PathLine { + x: bgPath.w + y: bgPath.h - bgPath.r + } + + PathArc { + x: bgPath.w - bgPath.r + y: bgPath.h + radiusX: bgPath.r + radiusY: bgPath.r + } + + PathLine { + x: bgPath.r + y: bgPath.h + } + + PathArc { + x: 0 + y: bgPath.h - bgPath.r + radiusX: bgPath.r + radiusY: bgPath.r + } + + PathLine { + x: 0 + y: bgPath.r + } + + PathArc { + x: bgPath.r + y: 0 + radiusX: bgPath.r + radiusY: bgPath.r + } + + } + + } + + LinearGradient { + id: fillGradient + + x1: 0 + y1: 0 + x2: root.availableWidth + y2: 0 + + GradientStop { + position: 0 + color: Qt.darker(effectiveFillColor, 1.2) + } + + GradientStop { + position: 1 + color: effectiveFillColor + } + + } + + // Active/filled track + Shape { + width: bgContainer.fillWidth + height: bgContainer.height + visible: bgContainer.fillWidth > 0 && bgContainer.height > 0 + preferredRendererType: Shape.CurveRenderer + clip: true + + ShapePath { + id: fillPath + + readonly property real fullWidth: root.availableWidth + readonly property real h: root.trackHeight + readonly property real r: root.trackRadius + + strokeColor: "transparent" + fillGradient: fillGradient + startX: r + startY: 0 + + PathLine { + x: fillPath.fullWidth - fillPath.r + y: 0 + } + + PathArc { + x: fillPath.fullWidth + y: fillPath.r + radiusX: fillPath.r + radiusY: fillPath.r + } + + PathLine { + x: fillPath.fullWidth + y: fillPath.h - fillPath.r + } + + PathArc { + x: fillPath.fullWidth - fillPath.r + y: fillPath.h + radiusX: fillPath.r + radiusY: fillPath.r + } + + PathLine { + x: fillPath.r + y: fillPath.h + } + + PathArc { + x: 0 + y: fillPath.h - fillPath.r + radiusX: fillPath.r + radiusY: fillPath.r + } + + PathLine { + x: 0 + y: fillPath.r + } + + PathArc { + x: fillPath.r + y: 0 + radiusX: fillPath.r + radiusY: fillPath.r + } + + } + + } + + // Circular cutout + Rectangle { + id: knobCutout + + implicitWidth: root.knobDiameter + root.cutoutExtra + implicitHeight: root.knobDiameter + root.cutoutExtra + radius: Math.min(Style.radiusL, width / 2) + color: root.cutoutColor !== undefined ? root.cutoutColor : Colors.mSurface + x: root.visualPosition * (root.availableWidth - root.knobDiameter) - root.cutoutExtra / 2 + anchors.verticalCenter: parent.verticalCenter + } + + } + + handle: Item { + implicitWidth: knobDiameter + implicitHeight: knobDiameter + x: root.leftPadding + root.visualPosition * (root.availableWidth - width) + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + id: knob + + implicitWidth: knobDiameter + implicitHeight: knobDiameter + radius: Math.min(Style.radiusL, width / 2) + color: root.pressed ? Colors.mHover : Colors.mSurface + border.color: effectiveFillColor + border.width: Style.borderL + anchors.centerIn: parent + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + MouseArea { + enabled: true + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + acceptedButtons: Qt.NoButton // Don't accept any mouse buttons - only hover + propagateComposedEvents: true + onEntered: { + root.hovering = true; + if (root.tooltipText) + TooltipService.show(knob, root.tooltipText, root.tooltipDirection); + + } + onExited: { + root.hovering = false; + if (root.tooltipText) + TooltipService.hide(); + + } + } + + // Hide tooltip when slider is pressed (anywhere on the slider) + Connections { + function onPressedChanged() { + if (root.pressed && root.tooltipText) + TooltipService.hide(); + + } + + target: root + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Components/UTabBar.qml b/config/quickshell/.config/quickshell/Components/UTabBar.qml new file mode 100644 index 0000000..3fd4d34 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UTabBar.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants + +Rectangle { + id: root + objectName: "NTabBar" + + // Public properties + property int currentIndex: 0 + property real spacing: Style.marginXS + property real margins: 0 + property real tabHeight: Style.baseWidgetSize + property bool distributeEvenly: false + default property alias content: tabRow.children + + onDistributeEvenlyChanged: _applyDistribution() + Component.onCompleted: _applyDistribution() + + function _updateFirstLast() { + // Defensive check for QML initialization timing + if (!tabRow || !tabRow.children) { + return; + } + var kids = tabRow.children; + var len = kids.length; + var firstVisible = -1; + var lastVisible = -1; + for (var i = 0; i < len; i++) { + var child = kids[i]; + // Only consider items that have isFirst/isLast (actual tab buttons, not Repeaters) + if (child.visible && "isFirst" in child) { + if (firstVisible === -1) + firstVisible = i; + lastVisible = i; + } + } + for (var i = 0; i < len; i++) { + var child = kids[i]; + if ("isFirst" in child) + child.isFirst = (i === firstVisible); + if ("isLast" in child) + child.isLast = (i === lastVisible); + } + } + + function _applyDistribution() { + if (!tabRow || !tabRow.children) { + return; + } + if (!distributeEvenly) { + for (var i = 0; i < tabRow.children.length; i++) { + var child = tabRow.children[i]; + child.Layout.fillWidth = true; + } + return; + } + + for (var i = 0; i < tabRow.children.length; i++) { + var child = tabRow.children[i]; + child.Layout.fillWidth = true; + child.Layout.preferredWidth = 1; + } + } + + // Styling + implicitWidth: tabRow.implicitWidth + (margins * 2) + implicitHeight: tabHeight + (margins * 2) + color: Colors.mSurfaceVariant + radius: Style.radiusM + + RowLayout { + id: tabRow + anchors.fill: parent + anchors.margins: margins + spacing: root.spacing + + onChildrenChanged: { + for (var i = 0; i < children.length; i++) { + var child = children[i]; + child.visibleChanged.connect(root._updateFirstLast); + } + root._updateFirstLast(); + root._applyDistribution(); + } + } +} diff --git a/config/quickshell/.config/quickshell/Components/UTabButton.qml b/config/quickshell/.config/quickshell/Components/UTabButton.qml new file mode 100644 index 0000000..1f4dcc9 --- /dev/null +++ b/config/quickshell/.config/quickshell/Components/UTabButton.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Constants +import qs.Components + +Rectangle { + id: root + + // Public properties + property string text: "" + property string icon: "" + property bool checked: false + property int tabIndex: 0 + property real pointSize: Style.fontSizeM + property bool isFirst: false + property bool isLast: false + + // Internal state + property bool isHovered: false + + signal clicked + + // Sizing + Layout.fillHeight: true + implicitWidth: contentLayout.implicitWidth + Style.marginM * 2 + + topLeftRadius: isFirst ? Style.radiusM : Style.radiusXXXS + bottomLeftRadius: isFirst ? Style.radiusM : Style.radiusXXXS + topRightRadius: isLast ? Style.radiusM : Style.radiusXXXS + bottomRightRadius: isLast ? Style.radiusM : Style.radiusXXXS + + color: root.isHovered ? Colors.mHover : (root.checked ? Colors.mPrimary : Colors.mSurface) + border.color: Colors.mOutline + border.width: Style.borderS + + Behavior on color { + enabled: !Colors.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + + // Content + RowLayout { + id: contentLayout + anchors.centerIn: parent + width: Math.min(implicitWidth, parent.width - Style.marginS * 2) + spacing: (root.icon !== "" && root.text !== "") ? Style.marginXS : 0 + + UIcon { + visible: root.icon !== "" + Layout.alignment: Qt.AlignVCenter + iconName: root.icon + iconSize: root.pointSize * 1.2 + color: root.isHovered ? Colors.mOnHover : (root.checked ? Colors.mOnPrimary : Colors.mOnSurface) + + Behavior on color { + enabled: !Colors.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + } + + UText { + id: tabText + visible: root.text !== "" + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + text: root.text + pointSize: root.pointSize + font.weight: Style.fontWeightSemiBold + color: root.isHovered ? Colors.mOnHover : (root.checked ? Colors.mOnPrimary : Colors.mOnSurface) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + Behavior on color { + enabled: !Colors.isTransitioning + ColorAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: { + root.isHovered = true; + } + onExited: { + root.isHovered = false; + } + onClicked: { + root.clicked(); + // Update parent NTabBar's currentIndex + if (root.parent && root.parent.parent && root.parent.parent.currentIndex !== undefined) { + root.parent.parent.currentIndex = root.tabIndex; + } + } + } +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NText.qml b/config/quickshell/.config/quickshell/Components/UText.qml similarity index 72% rename from config/quickshell/.config/quickshell/Noctalia/NText.qml rename to config/quickshell/.config/quickshell/Components/UText.qml index 83ec766..7434bc2 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NText.qml +++ b/config/quickshell/.config/quickshell/Components/UText.qml @@ -1,19 +1,17 @@ import QtQuick import QtQuick.Layouts import qs.Constants -import qs.Noctalia Text { id: root property string family: Fonts.primary property real pointSize: Style.fontSizeM - property real fontScale: 1 font.family: root.family font.weight: Style.fontWeightMedium - font.pointSize: root.pointSize * fontScale - color: Color.mOnSurface + font.pointSize: root.pointSize + color: Colors.mOnSurface elide: Text.ElideRight wrapMode: Text.NoWrap verticalAlignment: Text.AlignVCenter diff --git a/config/quickshell/.config/quickshell/Noctalia/NToggle.qml b/config/quickshell/.config/quickshell/Components/UToggle.qml similarity index 55% rename from config/quickshell/.config/quickshell/Noctalia/NToggle.qml rename to config/quickshell/.config/quickshell/Components/UToggle.qml index c1e1c53..57998d9 100644 --- a/config/quickshell/.config/quickshell/Noctalia/NToggle.qml +++ b/config/quickshell/.config/quickshell/Components/UToggle.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import qs.Components import qs.Constants RowLayout { @@ -8,38 +9,54 @@ RowLayout { property string label: "" property string description: "" + property string icon: "" property bool checked: false property bool hovering: false property int baseSize: Math.round(Style.baseWidgetSize * 0.8) + property var defaultValue: undefined + property string settingsPath: "" + readonly property bool isValueChanged: (defaultValue !== undefined) && (checked !== defaultValue) + readonly property string indicatorTooltip: defaultValue !== undefined ? I18n.tr("panels.indicator.default-value", { + "value": typeof defaultValue === "boolean" ? (defaultValue ? "true" : "false") : String(defaultValue) + }) : "" signal toggled(bool checked) signal entered() signal exited() Layout.fillWidth: true + opacity: enabled ? 1 : 0.6 + spacing: Style.marginM - NLabel { + ULabel { + Layout.fillWidth: true label: root.label description: root.description + icon: root.icon + iconColor: root.checked ? Colors.mPrimary : Colors.mOnSurface + visible: root.label !== "" || root.description !== "" + showIndicator: root.isValueChanged + indicatorTooltip: root.indicatorTooltip } Rectangle { id: switcher + Layout.alignment: Qt.AlignVCenter implicitWidth: Math.round(root.baseSize * 0.85) * 2 implicitHeight: Math.round(root.baseSize * 0.5) * 2 - radius: height * 0.5 - color: root.checked ? Color.mPrimary : Color.mSurface - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) + radius: Math.min(Style.radiusL, height / 2) + color: root.checked ? Colors.mPrimary : Colors.mSurface + border.color: Colors.mOutline + border.width: Style.borderS Rectangle { implicitWidth: Math.round(root.baseSize * 0.4) * 2 implicitHeight: Math.round(root.baseSize * 0.4) * 2 - radius: height * 0.5 - color: root.checked ? Color.mOnPrimary : Color.mPrimary - border.color: root.checked ? Color.mSurface : Color.mSurface - border.width: Math.max(1, Style.borderM) + radius: Math.min(Style.radiusL, height / 2) + color: root.checked ? Colors.mOnPrimary : Colors.mPrimary + border.color: root.checked ? Colors.mSurface : Colors.mSurface + border.width: Style.borderM anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: 0 x: root.checked ? switcher.width - width - 3 : 3 @@ -55,18 +72,28 @@ RowLayout { } MouseArea { + enabled: root.enabled anchors.fill: parent cursorShape: Qt.PointingHandCursor hoverEnabled: true onEntered: { + if (!enabled) + return ; + hovering = true; root.entered(); } onExited: { + if (!enabled) + return ; + hovering = false; root.exited(); } onClicked: { + if (!enabled) + return ; + root.toggled(!root.checked); } } diff --git a/config/quickshell/.config/quickshell/Constants/Color.qml b/config/quickshell/.config/quickshell/Constants/Color.qml deleted file mode 100644 index 84aac1a..0000000 --- a/config/quickshell/.config/quickshell/Constants/Color.qml +++ /dev/null @@ -1,26 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Constants -pragma Singleton - -Singleton { - id: root - - // Compatibility colors for noctalia modules - readonly property color mPrimary: Colors.primary - readonly property color mOnPrimary: Colors.base - readonly property color mSecondary: Colors.primary - readonly property color mOnSecondary: Colors.base - readonly property color mTertiary: Colors.primary - readonly property color mOnTertiary: Colors.base - readonly property color mError: Colors.red - readonly property color mOnError: Colors.base - readonly property color mSurface: Colors.base - readonly property color mOnSurface: Colors.text - readonly property color mSurfaceVariant: Colors.surface - readonly property color mOnSurfaceVariant: Colors.overlay1 - readonly property color mOutline: Colors.primary - readonly property color mShadow: Colors.crust - readonly property color transparent: "transparent" -} diff --git a/config/quickshell/.config/quickshell/Constants/Colors.qml b/config/quickshell/.config/quickshell/Constants/Colors.qml index 4732210..e43cfe3 100644 --- a/config/quickshell/.config/quickshell/Constants/Colors.qml +++ b/config/quickshell/.config/quickshell/Constants/Colors.qml @@ -1,40 +1,154 @@ import QtQuick import Quickshell -import qs.Services +import Quickshell.Io +import qs.Constants +import qs.Utils pragma Singleton Singleton { id: root - readonly property color primary: SettingsService.primaryColor - readonly property color transparent: "transparent" - readonly property color rosewater: "#f5e0dc" - readonly property color flamingo: "#f2cdcd" - readonly property color pink: "#f5c2e7" - readonly property color mauve: "#cba6f7" - readonly property color red: "#f38ba8" - readonly property color maroon: "#eba0ac" - readonly property color peach: "#fab387" - readonly property color yellow: "#f9e2af" - readonly property color green: "#a6e3a1" - readonly property color teal: "#94e2d5" - readonly property color sky: "#89dceb" - readonly property color sapphire: "#74c7ec" - readonly property color blue: "#89b4fa" - readonly property color lavender: "#b4befe" - readonly property color text: "#cdd6f4" - readonly property color subtext1: "#bac2de" - readonly property color subtext0: "#a6adc8" - readonly property color overlay2: "#9399b2" - readonly property color overlay1: "#7f849c" - readonly property color overlay0: "#6c7086" - readonly property color surface2: "#585b70" - readonly property color surface1: "#45475a" - readonly property color surface0: "#313244" - readonly property color surface: "#292a3c" - readonly property color base: "#1e1e2e" - readonly property color mantle: "#181825" - readonly property color crust: "#11111b" - readonly property color distroColor: "#74c7ec" - readonly property var cavaList: ["#b4befe", "#89b4fa", "#74c7ec", "#89dceb", "#94e2d5", "#a6e3a1", "#f9e2af", "#fab387"] + // Part of material3 color scheme + property color mPrimary: defaultColors.mPrimary + property color mOnPrimary: defaultColors.mOnPrimary + property color mError: defaultColors.mError + property color mOnError: defaultColors.mOnError + property color mSurface: defaultColors.mSurface + property color mOnSurface: defaultColors.mOnSurface + property color mSurfaceVariant: defaultColors.mSurfaceVariant + property color mOnSurfaceVariant: defaultColors.mOnSurfaceVariant + property color mOutline: defaultColors.mOutline + property color mShadow: defaultColors.mShadow + property color mHover: defaultColors.mHover + property color mOnHover: defaultColors.mOnHover + // Supplementary colors + property color mPink: defaultColors.mPink + property color mPurple: defaultColors.mPurple + property color mRed: defaultColors.mRed + property color mOrange: defaultColors.mOrange + property color mYellow: defaultColors.mYellow + property color mGreen: defaultColors.mGreen + property color mCyan: defaultColors.mCyan + property color mSky: defaultColors.mSky + property color mBlue: defaultColors.mBlue + property color mLavender: defaultColors.mLavender + // Special colors + property color distro: "#74c7ec" + property color transparent: "#00000000" + readonly property var cavaList: [mLavender, mBlue, mSky, mCyan, mGreen, mYellow, mOrange, mRed] + + function reloadColors(newColors) { + if (typeof newColors === "string") { + try { + newColors = JSON.parse(newColors); + } catch (e) { + Logger.e("Colors", "Failed to parse colors.json, using default colors. Error:", e); + return ; + } + } else if (typeof newColors !== "object") { + Logger.w("Colors", "Invalid colors data, using default colors. Data:", newColors); + return ; + } + mPrimary = newColors.mPrimary || defaultColors.mPrimary; + mOnPrimary = newColors.mOnPrimary || defaultColors.mOnPrimary; + mError = newColors.mError || defaultColors.mError; + mOnError = newColors.mOnError || defaultColors.mOnError; + mSurface = newColors.mSurface || defaultColors.mSurface; + mOnSurface = newColors.mOnSurface || defaultColors.mOnSurface; + mSurfaceVariant = newColors.mSurfaceVariant || defaultColors.mSurfaceVariant; + mOnSurfaceVariant = newColors.mOnSurfaceVariant || defaultColors.mOnSurfaceVariant; + mOutline = newColors.mOutline || defaultColors.mOutline; + mShadow = newColors.mShadow || defaultColors.mShadow; + mHover = newColors.mHover || defaultColors.mHover; + mOnHover = newColors.mOnHover || defaultColors.mOnHover; + mPink = newColors.mPink || defaultColors.mPink; + mPurple = newColors.mPurple || defaultColors.mPurple; + mRed = newColors.mRed || defaultColors.mRed; + mOrange = newColors.mOrange || defaultColors.mOrange; + mYellow = newColors.mYellow || defaultColors.mYellow; + mGreen = newColors.mGreen || defaultColors.mGreen; + mCyan = newColors.mCyan || defaultColors.mCyan; + mSky = newColors.mSky || defaultColors.mSky; + mBlue = newColors.mBlue || defaultColors.mBlue; + mLavender = newColors.mLavender || defaultColors.mLavender; + } + + function setColor(name, value) { + if (!adapter.colors) + adapter.colors = { + }; + + adapter.colors[name] = value; + colorFile.writeAdapter(); + } + + function unsetColor(name) { + if (!adapter.colors || !(name in adapter.colors)) + return ; + + delete adapter.colors[name]; + colorFile.writeAdapter(); + } + + QtObject { + id: defaultColors + + readonly property color mPrimary: "#89b4fa" + readonly property color mOnPrimary: "#11111b" + readonly property color mError: "#f38ba8" + readonly property color mOnError: "#11111b" + readonly property color mSurface: "#1e1e2e" + readonly property color mOnSurface: "#cdd6f4" + readonly property color mSurfaceVariant: "#313244" + readonly property color mOnSurfaceVariant: "#a6adc8" + readonly property color mOutline: "#585b70" + readonly property color mShadow: "#11111b" + readonly property color mHover: "#45475a" + readonly property color mOnHover: "#cdd6f4" + readonly property color mPink: "#f5c2e7" + readonly property color mPurple: "#cba6f7" + readonly property color mRed: "#f38ba8" + readonly property color mOrange: "#fab387" + readonly property color mYellow: "#f9e2af" + readonly property color mGreen: "#a6e3a1" + readonly property color mCyan: "#94e2d5" + readonly property color mSky: "#74c7ec" + readonly property color mBlue: "#89b4fa" + readonly property color mLavender: "#b4befe" + } + + FileView { + id: colorFile + + path: Paths.configDir + "colors.json" + printErrors: false + watchChanges: true + onFileChanged: reload() + + JsonAdapter { + id: adapter + + property var colors: ({ + }) + } + + } + + Connections { + function onColorsChanged() { + colorReloadTimer.restart(); + } + + target: adapter + } + + Timer { + id: colorReloadTimer + + interval: 50 + running: true + repeat: false + onTriggered: reloadColors(adapter.colors) + } + } diff --git a/config/quickshell/.config/quickshell/Constants/Fonts.qml b/config/quickshell/.config/quickshell/Constants/Fonts.qml index 1499e83..5785a93 100644 --- a/config/quickshell/.config/quickshell/Constants/Fonts.qml +++ b/config/quickshell/.config/quickshell/Constants/Fonts.qml @@ -8,9 +8,6 @@ Singleton { readonly property string primary: "LXGW WenKai" readonly property string nerd: "Meslo LGM Nerd Font Mono" + readonly property string icon: Icons.fontFamily readonly property string sans: "LXGW WenKai" - readonly property int small: Style.fontSizeS - readonly property int medium: Style.fontSizeM - readonly property int large: Style.fontSizeL - readonly property int icon: 14 // for nerd font } diff --git a/config/quickshell/.config/quickshell/Constants/Icons.qml b/config/quickshell/.config/quickshell/Constants/Icons.qml index 9cf2077..00ef209 100644 --- a/config/quickshell/.config/quickshell/Constants/Icons.qml +++ b/config/quickshell/.config/quickshell/Constants/Icons.qml @@ -1,53 +1,19 @@ import QtQuick +import QtQuick.Controls import Quickshell +import qs.Constants import qs.Utils pragma Singleton Singleton { id: root - // Nerd fonts icons - readonly property string distro: "󰣇" - readonly property string tray: "" - readonly property string idleInhibitorActivated: "󰅶" - readonly property string idleInhibitorDeactivated: "󰾪" - readonly property string powerMenu: "󰐥" - readonly property string volumeHigh: "" - readonly property string volumeMedium: "" - readonly property string volumeLow: "" - readonly property string volumeMuted: "󰝟" - readonly property string brightness: "" - readonly property string charging: "" - readonly property string battery100: "" - readonly property string battery75: "" - readonly property string battery50: "" - readonly property string battery25: "" - readonly property string battery00: "" - readonly property string cpu: "󰘚" - readonly property string memory: "󰍛" - readonly property string tempHigh: "" - readonly property string tempMedium: "" - readonly property string tempLow: "" - readonly property string ip: "󰇧" - readonly property string upload: "" - readonly property string download: "" - readonly property string speedSlower: "󰾆" - readonly property string speedFaster: "󰓅" - readonly property string speedReset: "󰾅" - readonly property string reset: "󰑙" - readonly property string lines: "" - readonly property string record: "" - readonly property string wifiOn: "󰖩" - readonly property string wifiOff: "󰖪" - readonly property string bluetoothOn: "" - readonly property string bluetoothOff: "󰂲" - // Tabler icons // Expose the font family name for easy access readonly property string fontFamily: currentFontLoader ? currentFontLoader.name : "" - readonly property string defaultIcon: TablerIcons.defaultIcon - readonly property var icons: TablerIcons.icons - readonly property var aliases: TablerIcons.aliases - readonly property string fontPath: "/Assets/Fonts/tabler/tabler-icons.ttf" + readonly property string defaultIcon: IconsTabler.defaultIcon + readonly property var icons: IconsTabler.icons + readonly property var aliases: IconsTabler.aliases + readonly property string fontPath: "/Assets/Fonts/tabler/noctalia-tabler-icons.ttf" // Current active font loader property FontLoader currentFontLoader: null property int fontVersion: 0 @@ -68,6 +34,7 @@ Singleton { } function loadFontWithCacheBusting() { + Logger.d("Icons", "Loading font with cache busting"); // Destroy old loader first if (currentFontLoader) { currentFontLoader.destroy(); @@ -82,24 +49,29 @@ Singleton { `, root, "dynamicFontLoader_" + fontVersion); // Connect to the new loader's status changes currentFontLoader.statusChanged.connect(function() { - if (currentFontLoader.status === FontLoader.Ready) + if (currentFontLoader.status === FontLoader.Ready) { + Logger.d("Icons", "Font loaded successfully:", currentFontLoader.name, "(version " + fontVersion + ")"); fontReloaded(); - else if (currentFontLoader.status === FontLoader.Error) - Logger.error("Icons", "Font failed to load (version " + fontVersion + ")"); + } else if (currentFontLoader.status === FontLoader.Error) { + Logger.e("Icons", "Font failed to load (version " + fontVersion + ")"); + } }); } function reloadFont() { + Logger.d("Icons", "Forcing font reload..."); fontVersion++; loadFontWithCacheBusting(); } Component.onCompleted: { + Logger.i("Icons", "Service started"); loadFontWithCacheBusting(); } Connections { function onReloadCompleted() { + Logger.d("Icons", "Quickshell reload completed - forcing font reload"); reloadFont(); } diff --git a/config/quickshell/.config/quickshell/Constants/TablerIcons.qml b/config/quickshell/.config/quickshell/Constants/IconsTabler.qml similarity index 97% rename from config/quickshell/.config/quickshell/Constants/TablerIcons.qml rename to config/quickshell/.config/quickshell/Constants/IconsTabler.qml index 39743ad..8b3dfca 100644 --- a/config/quickshell/.config/quickshell/Constants/TablerIcons.qml +++ b/config/quickshell/.config/quickshell/Constants/IconsTabler.qml @@ -33,6 +33,7 @@ Singleton { "media-next": "player-skip-forward-filled", "download-speed": "download", "upload-speed": "upload", + "cpu-intensive": "alert-octagon", "cpu-usage": "brand-speedtest", "cpu-temperature": "flame", "gpu-temperature": "device-desktop", @@ -42,6 +43,7 @@ Singleton { "powersaver": "leaf", "storage": "database", "ethernet": "sitemap", + "ethernet-off": "sitemap-off", "keyboard": "keyboard", "shutdown": "power", "lock": "lock", @@ -49,6 +51,7 @@ Singleton { "logout": "logout", "reboot": "refresh", "suspend": "player-pause", + "hibernate": "zzz", "nightlight-on": "moon", "nightlight-off": "moon-off", "nightlight-forced": "moon-stars", @@ -71,15 +74,19 @@ Singleton { "chevron-down": "chevron-down", "caret-up": "caret-up-filled", "caret-down": "caret-down-filled", + "caret-left": "caret-left-filled", + "caret-right": "caret-right-filled", "star": "star", "star-off": "star-off", "battery-exclamation": "battery-exclamation", "battery-charging": "battery-charging", + "battery-charging-2": "battery-charging-2", "battery-4": "battery-4", "battery-3": "battery-3", "battery-2": "battery-2", "battery-1": "battery-1", "battery": "battery", + "battery-off": "battery-off", "wifi-0": "wifi-0", "wifi-1": "wifi-1", "wifi-2": "wifi-2", @@ -88,10 +95,14 @@ Singleton { "microphone": "microphone", "microphone-mute": "microphone-off", "volume-mute": "volume-off", + "volume-x": "volume-3", "volume-zero": "volume-3", "volume-low": "volume-2", "volume-high": "volume", "weather-sun": "sun", + "weather-moon": "moon", + "weather-moon-stars": "moon-stars", + "weather-cloud-off": "cloud-off", "weather-cloud": "cloud", "weather-cloud-haze": "cloud-fog", "weather-cloud-lightning": "cloud-bolt", @@ -101,24 +112,33 @@ Singleton { "brightness-low": "brightness-down-filled", "brightness-high": "brightness-up-filled", "settings-general": "adjustments-horizontal", - "settings-bar": "capsule-horizontal", + "settings-bar": "crop-16-9", + "settings-user-interface": "layout-board", + "settings-control-center": "adjustments-horizontal", "settings-dock": "layout-bottombar", "settings-launcher": "rocket", "settings-audio": "device-speaker", "settings-display": "device-desktop", - "settings-network": "sitemap", + "settings-network": "circles-relation", "settings-brightness": "brightness-up", "settings-location": "world-pin", "settings-color-scheme": "palette", "settings-wallpaper": "paint", "settings-wallpaper-selector": "library-photo", - "settings-screen-recorder": "video", "settings-hooks": "link", "settings-notifications": "bell", "settings-osd": "picture-in-picture", "settings-about": "info-square-rounded", + "settings-idle": "moon", + "settings-lock-screen": "lock", + "settings-session-menu": "power", + "settings-system-monitor": "activity", "bluetooth": "bluetooth", "bt-device-generic": "bluetooth", + "bt-device-gamepad": "device-gamepad-2", + "bt-device-microphone": "microphone", + "bt-device-headset": "headset", + "bt-device-earbuds": "device-airpods", "bt-device-headphones": "headphones", "bt-device-mouse": "mouse-2", "bt-device-keyboard": "bluetooth", @@ -126,6 +146,12 @@ Singleton { "bt-device-watch": "device-watch", "bt-device-speaker": "device-speaker", "bt-device-tv": "device-tv", + "antenna-bars-1": "antenna-bars-1", + "antenna-bars-2": "antenna-bars-2", + "antenna-bars-3": "antenna-bars-3", + "antenna-bars-4": "antenna-bars-4", + "antenna-bars-5": "antenna-bars-5", + "antenna-bars-off": "antenna-bars-off", "noctalia": "noctalia", "hyprland": "hyprland", "filepicker-folder": "folder", @@ -152,7 +178,10 @@ Singleton { "filepicker-text": "file-text", "filepicker-eye": "eye", "filepicker-eye-off": "eye-off", - "filepicker-folder-current": "checks" + "filepicker-folder-current": "checks", + "plugin": "plug-connected", + "info": "file-description", + "official-plugin": "shield-filled" } // Fonts Codepoints - do not change! @@ -295,8 +324,8 @@ Singleton { "align-left": "\u{ea09}", "align-left-2": "\u{ff00}", "align-right": "\u{ea0a}", - "alpha"//"align-right-2": "\u{feff}", - : "\u{f543}", + //"align-right-2": "\u{feff}", + "alpha": "\u{f543}", "alphabet-arabic": "\u{ff2f}", "alphabet-bangla": "\u{ff2e}", "alphabet-cyrillic": "\u{f1df}", @@ -2084,7 +2113,7 @@ Singleton { "cloud-snow": "\u{ea73}", "cloud-star": "\u{f85b}", "cloud-storm": "\u{ea74}", - "cloud-sun": "\u{ea7a}", + "cloud-sun": "\u{ec6d}", "cloud-up": "\u{f85c}", "cloud-upload": "\u{ea75}", "cloud-x": "\u{f85d}", @@ -3128,8 +3157,8 @@ Singleton { "friends": "\u{eab0}", "friends-off": "\u{f136}", "frustum": "\u{fa9f}", - "frustum-plus"//"frustum-off": "\u{fa9d}", - : "\u{fa9e}", + //"frustum-off": "\u{fa9d}", + "frustum-plus": "\u{fa9e}", "function": "\u{f225}", "function-filled": "\u{fc2b}", "function-off": "\u{f3f0}", @@ -3388,13 +3417,13 @@ Singleton { "hexagon-letter-x": "\u{f479}", "hexagon-letter-x-filled": "\u{fe30}", "hexagon-letter-y": "\u{f47a}", - "hexagon-letter-z"//"hexagon-letter-y-filled": "\u{fe2f}", - : "\u{f47b}", - "hexagon-minus"//"hexagon-letter-z-filled": "\u{fe2e}", - : "\u{fc8f}", + //"hexagon-letter-y-filled": "\u{fe2f}", + "hexagon-letter-z": "\u{f47b}", + //"hexagon-letter-z-filled": "\u{fe2e}", + "hexagon-minus": "\u{fc8f}", "hexagon-minus-2": "\u{fc8e}", - "hexagon-number-0"//"hexagon-minus-filled": "\u{fe2d}", - : "\u{f459}", + //"hexagon-minus-filled": "\u{fe2d}", + "hexagon-number-0": "\u{f459}", "hexagon-number-0-filled": "\u{f74c}", "hexagon-number-1": "\u{f45a}", "hexagon-number-1-filled": "\u{f74d}", @@ -3417,8 +3446,8 @@ Singleton { "hexagon-off": "\u{ee9c}", "hexagon-plus": "\u{fc45}", "hexagon-plus-2": "\u{fc90}", - "hexagonal-prism"//"hexagon-plus-filled": "\u{fe2c}", - : "\u{faa5}", + //"hexagon-plus-filled": "\u{fe2c}", + "hexagonal-prism": "\u{faa5}", "hexagonal-prism-off": "\u{faa3}", "hexagonal-prism-plus": "\u{faa4}", "hexagonal-pyramid": "\u{faa8}", @@ -3448,8 +3477,8 @@ Singleton { "home-eco": "\u{f351}", "home-edit": "\u{f352}", "home-exclamation": "\u{f33c}", - "home-hand"//"home-filled": "\u{fe2b}", - : "\u{f504}", + //"home-filled": "\u{fe2b}", + "home-hand": "\u{f504}", "home-heart": "\u{f353}", "home-infinity": "\u{f505}", "home-link": "\u{f354}", @@ -3567,8 +3596,8 @@ Singleton { "ironing-2-filled": "\u{1006e}", "ironing-3": "\u{f2f6}", "ironing-3-filled": "\u{1006d}", - "ironing-off"//"ironing-filled": "\u{fe2a}", - : "\u{f2f7}", + //"ironing-filled": "\u{fe2a}", + "ironing-off": "\u{f2f7}", "ironing-steam": "\u{f2f9}", "ironing-steam-filled": "\u{1006c}", "ironing-steam-off": "\u{f2f8}", @@ -3578,8 +3607,8 @@ Singleton { "italic": "\u{eb93}", "jacket": "\u{f661}", "jetpack": "\u{f581}", - "jewish-star"//"jetpack-filled": "\u{fe29}", - : "\u{f3ff}", + //"jetpack-filled": "\u{fe29}", + "jewish-star": "\u{f3ff}", "jewish-star-filled": "\u{f67e}", "join-bevel": "\u{ff4c}", "join-round": "\u{ff4b}", @@ -3593,8 +3622,8 @@ Singleton { "kering": "\u{efb8}", "kerning": "\u{efb8}", "key": "\u{eac7}", - "key-off"//"key-filled": "\u{fe28}", - : "\u{f14b}", + //"key-filled": "\u{fe28}", + "key-off": "\u{f14b}", "keyboard": "\u{ebd6}", "keyboard-filled": "\u{100a2}", "keyboard-hide": "\u{ec7e}", @@ -3650,20 +3679,20 @@ Singleton { "layers-union": "\u{eacb}", "layout": "\u{eadb}", "layout-2": "\u{eacc}", - "layout-align-left"//"layout-2-filled": "\u{fe27}", - // "layout-align-bottom": "\u{eacd}", + //"layout-2-filled": "\u{fe27}", + //"layout-align-bottom": "\u{eacd}", //"layout-align-bottom-filled": "\u{fe26}", - // "layout-align-center": "\u{eace}", + //"layout-align-center": "\u{eace}", //"layout-align-center-filled": "\u{fe25}", - : "\u{eacf}", - "layout-align-middle"// "layout-align-left-filled": "\u{fe24}", - : "\u{ead0}", - "layout-align-right"//"layout-align-middle-filled": "\u{fe23}", - : "\u{ead1}", - "layout-align-top"//"layout-align-right-filled": "\u{fe22}", - : "\u{ead2}", - "layout-board"//"layout-align-top-filled": "\u{fe21}", - : "\u{ef95}", + "layout-align-left": "\u{eacf}", + //"layout-align-left-filled": "\u{fe24}", + "layout-align-middle": "\u{ead0}", + //"layout-align-middle-filled": "\u{fe23}", + "layout-align-right": "\u{ead1}", + //"layout-align-right-filled": "\u{fe22}", + "layout-align-top": "\u{ead2}", + //"layout-align-top-filled": "\u{fe21}", + "layout-board": "\u{ef95}", "layout-board-filled": "\u{10182}", "layout-board-split": "\u{ef94}", "layout-board-split-filled": "\u{10183}", @@ -3675,8 +3704,8 @@ Singleton { "layout-bottombar-filled": "\u{fc37}", "layout-bottombar-inactive": "\u{fd45}", "layout-cards": "\u{ec13}", - "layout-collage"// "layout-cards-filled": "\u{fe20}", - : "\u{f389}", + //"layout-cards-filled": "\u{fe20}", + "layout-collage": "\u{f389}", "layout-columns": "\u{ead4}", "layout-dashboard": "\u{f02c}", "layout-dashboard-filled": "\u{fe1f}", @@ -4157,14 +4186,14 @@ Singleton { "microphone": "\u{eaf0}", "microphone-2": "\u{ef2c}", "microphone-2-off": "\u{f40d}", - "microphone-off"//"microphone-filled": "\u{fe0f}", - : "\u{ed16}", + //"microphone-filled": "\u{fe0f}", + "microphone-off": "\u{ed16}", "microscope": "\u{ef64}", "microscope-filled": "\u{10166}", "microscope-off": "\u{f40e}", "microwave": "\u{f248}", - "microwave-off"//"microwave-filled": "\u{fe0e}", - : "\u{f264}", + //"microwave-filled": "\u{fe0e}", + "microwave-off": "\u{f264}", "military-award": "\u{f079}", "military-rank": "\u{efcf}", "military-rank-filled": "\u{ff5e}", @@ -4398,18 +4427,18 @@ Singleton { "number-4-small": "\u{fcf9}", "number-40-small": "\u{fffa}", "number-41-small": "\u{fff9}", - "number-5"//"number-42-small": "\u{fff8}", - // "number-43-small": "\u{fff7}", - // "number-44-small": "\u{fff6}", - // "number-45-small": "\u{fff5}", - // "number-46-small": "\u{fff4}", - // "number-47-small": "\u{fff3}", - // "number-48-small": "\u{fff2}", - // "number-49-small": "\u{fff1}", - : "\u{edf5}", + //"number-42-small": "\u{fff8}", + //"number-43-small": "\u{fff7}", + //"number-44-small": "\u{fff6}", + //"number-45-small": "\u{fff5}", + //"number-46-small": "\u{fff4}", + //"number-47-small": "\u{fff3}", + //"number-48-small": "\u{fff2}", + //"number-49-small": "\u{fff1}", + "number-5": "\u{edf5}", "number-5-small": "\u{fcfa}", - "number-51-small"// "number-50-small": "\u{fff0}", - : "\u{ffef}", + //"number-50-small": "\u{fff0}", + "number-51-small": "\u{ffef}", "number-52-small": "\u{ffee}", "number-53-small": "\u{ffed}", "number-54-small": "\u{ffec}", @@ -4761,6 +4790,7 @@ Singleton { "playstation-triangle": "\u{f2af}", "playstation-x": "\u{f2b0}", "plug": "\u{ebd9}", + "plug-filled": "\u{f6b3}", "plug-connected": "\u{f00a}", "plug-connected-x": "\u{f0a0}", "plug-off": "\u{f180}", @@ -4849,11 +4879,11 @@ Singleton { "quote": "\u{efbe}", "quote-filled": "\u{1009c}", "quote-off": "\u{f188}", - "radar"//"quotes": "\u{fb1e}", - : "\u{f017}", + //"quotes": "\u{fb1e}", + "radar": "\u{f017}", "radar-2": "\u{f016}", - "radar-off"//"radar-filled": "\u{fe0d}", - : "\u{f41f}", + //"radar-filled": "\u{fe0d}", + "radar-off": "\u{f41f}", "radio": "\u{ef2d}", "radio-off": "\u{f420}", "radioactive": "\u{ecc0}", @@ -4913,12 +4943,12 @@ Singleton { "regex-off": "\u{f421}", "registered": "\u{eb14}", "relation-many-to-many": "\u{ed7f}", - "relation-one-to-many"//"relation-many-to-many-filled": "\u{fe0c}", - : "\u{ed80}", - "relation-one-to-one"//"relation-one-to-many-filled": "\u{fe0b}", - : "\u{ed81}", - "reload"//"relation-one-to-one-filled": "\u{fe0a}", - : "\u{f3ae}", + //"relation-many-to-many-filled": "\u{fe0c}", + "relation-one-to-many": "\u{ed80}", + //"relation-one-to-many-filled": "\u{fe0b}", + "relation-one-to-one": "\u{ed81}", + //"relation-one-to-one-filled": "\u{fe0a}", + "reload": "\u{f3ae}", "reorder": "\u{fc15}", "repeat": "\u{eb72}", "repeat-off": "\u{f18e}", @@ -5070,8 +5100,8 @@ Singleton { "search": "\u{eb1c}", "search-off": "\u{f19c}", "section": "\u{eed5}", - "section-sign"//"section-filled": "\u{fe09}", - : "\u{f019}", + //"section-filled": "\u{fe09}", + "section-sign": "\u{f019}", "seeding": "\u{ed51}", "seeding-filled": "\u{10006}", "seeding-off": "\u{f19d}", @@ -5279,8 +5309,8 @@ Singleton { "sort-z-a": "\u{f550}", "sos": "\u{f24a}", "soup": "\u{ef2e}", - "soup-off"//"soup-filled": "\u{fe08}", - : "\u{f42d}", + //"soup-filled": "\u{fe08}", + "soup-off": "\u{f42d}", "source-code": "\u{f4a2}", "space": "\u{ec0c}", "space-off": "\u{f1aa}", @@ -5373,22 +5403,22 @@ Singleton { "square-half": "\u{effb}", "square-key": "\u{f638}", "square-letter-a": "\u{f47c}", - "square-letter-b"//"square-letter-a-filled": "\u{fe07}", - : "\u{f47d}", - "square-letter-c"//"square-letter-b-filled": "\u{fe06}", - : "\u{f47e}", - "square-letter-d"//"square-letter-c-filled": "\u{fe05}", - : "\u{f47f}", - "square-letter-e"//"square-letter-d-filled": "\u{fe04}", - : "\u{f480}", - "square-letter-f"//"square-letter-e-filled": "\u{fe03}", - : "\u{f481}", - "square-letter-g"//"square-letter-f-filled": "\u{fe02}", - : "\u{f482}", - "square-letter-h"//"square-letter-g-filled": "\u{fe01}", - : "\u{f483}", - "square-letter-i"//"square-letter-h-filled": "\u{fe00}", - : "\u{f484}", + //"square-letter-a-filled": "\u{fe07}", + "square-letter-b": "\u{f47d}", + //"square-letter-b-filled": "\u{fe06}", + "square-letter-c": "\u{f47e}", + //"square-letter-c-filled": "\u{fe05}", + "square-letter-d": "\u{f47f}", + //"square-letter-d-filled": "\u{fe04}", + "square-letter-e": "\u{f480}", + //"square-letter-e-filled": "\u{fe03}", + "square-letter-f": "\u{f481}", + //"square-letter-f-filled": "\u{fe02}", + "square-letter-g": "\u{f482}", + //"square-letter-g-filled": "\u{fe01}", + "square-letter-h": "\u{f483}", + //"square-letter-h-filled": "\u{fe00}", + "square-letter-i": "\u{f484}", "square-letter-i-filled": "\u{fdff}", "square-letter-j": "\u{f485}", "square-letter-j-filled": "\u{fdfe}", diff --git a/config/quickshell/.config/quickshell/Constants/Paths.qml b/config/quickshell/.config/quickshell/Constants/Paths.qml new file mode 100644 index 0000000..35cd1e3 --- /dev/null +++ b/config/quickshell/.config/quickshell/Constants/Paths.qml @@ -0,0 +1,11 @@ +import QtQuick +import Quickshell +pragma Singleton + +Singleton { + id: root + + readonly property string cacheDir: Quickshell.shellDir + "/Assets/Cache/" + readonly property string configDir: Quickshell.shellDir + "/Assets/Config/" + readonly property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/" +} diff --git a/config/quickshell/.config/quickshell/Constants/Style.qml b/config/quickshell/.config/quickshell/Constants/Style.qml index 9580669..d67c36c 100644 --- a/config/quickshell/.config/quickshell/Constants/Style.qml +++ b/config/quickshell/.config/quickshell/Constants/Style.qml @@ -4,10 +4,6 @@ import Quickshell.Io pragma Singleton Singleton { - /* - Preset sizes for font, radii, ? - */ - id: root // Font size @@ -19,6 +15,7 @@ Singleton { readonly property real fontSizeXL: 16 readonly property real fontSizeXXL: 18 readonly property real fontSizeXXXL: 24 + readonly property real fontNerd: 16 // Font weight readonly property int fontWeightRegular: 400 readonly property int fontWeightMedium: 500 @@ -50,19 +47,22 @@ Singleton { readonly property real opacityHeavy: 0.75 readonly property real opacityAlmost: 0.95 readonly property real opacityFull: 1 + // Shadows + readonly property real shadowOpacity: 0.85 + readonly property real shadowBlur: 1 + readonly property int shadowBlurMax: 22 + readonly property real shadowHorizontalOffset: 2 + readonly property real shadowVerticalOffset: 3 // Animation duration (ms) readonly property int animationFast: 150 readonly property int animationNormal: 300 readonly property int animationSlow: 450 readonly property int animationSlowest: 1000 - // Delays - readonly property int tooltipDelay: 300 - readonly property int tooltipDelayLong: 1200 - readonly property int pillDelay: 500 // Settings widgets base size - readonly property real baseWidgetSize: 33 - readonly property real sliderWidth: 200 + readonly property int baseWidgetSize: 33 + readonly property int sliderWidth: 200 // Bar Dimensions - readonly property real barHeight: 45 - readonly property real capsuleHeight: 35 + readonly property int barHeight: 45 + readonly property int sidebarWidth: 360 + readonly property int capsuleHeight: 35 } diff --git a/config/quickshell/.config/quickshell/Modules/Background/Background.qml b/config/quickshell/.config/quickshell/Modules/Background/Background.qml new file mode 100644 index 0000000..1630641 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Background/Background.qml @@ -0,0 +1,167 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Constants +import qs.Services + +Variants { + model: Quickshell.screens + + Item { + property var modelData + + PanelWindow { + screen: modelData + WlrLayershell.namespace: "quickshell-background" + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors { + top: true + bottom: true + left: true + right: true + } + + Rectangle { + anchors.fill: parent + color: Colors.mSurface + + Item { + id: bgManager + + property string activeSource: BackgroundService.previewPath || (BarService.focusMode ? BackgroundService.cachedBlurredPath : BackgroundService.cachedPath) + property bool showFirst: true + + anchors.fill: parent + onActiveSourceChanged: { + showFirst = !showFirst; + if (showFirst) + img1.source = activeSource; + else + img2.source = activeSource; + } + Component.onCompleted: { + if (showFirst) + img1.source = activeSource; + else + img2.source = activeSource; + } + + Image { + id: img1 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (bgManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + Image { + id: img2 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (!bgManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + } + + } + + } + + PanelWindow { + screen: modelData + WlrLayershell.namespace: "quickshell-backdrop" + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors { + top: true + bottom: true + left: true + right: true + } + + Rectangle { + anchors.fill: parent + color: Colors.mSurface + + Item { + id: backdropManager + + property string activeSource: BackgroundService.cachedBlurredPath + property bool showFirst: true + + anchors.fill: parent + onActiveSourceChanged: { + showFirst = !showFirst; + if (showFirst) + backImg1.source = activeSource; + else + backImg2.source = activeSource; + } + Component.onCompleted: { + if (showFirst) + backImg1.source = activeSource; + else + backImg2.source = activeSource; + } + + Image { + id: backImg1 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (backdropManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + Image { + id: backImg2 + + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + opacity: (!backdropManager.showFirst && status === Image.Ready) ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Style.animationSlow + } + + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml b/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml index 41431a2..125674e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Bar.qml @@ -3,12 +3,11 @@ import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Io -import Quickshell.Services.UPower import Quickshell.Wayland +import qs.Components import qs.Constants import qs.Modules.Bar.Components -import qs.Modules.Bar.Misc -import qs.Modules.Misc +import qs.Modules.Bar.Modules import qs.Services Variants { @@ -22,6 +21,7 @@ Variants { screen: modelData WlrLayershell.namespace: "quickshell-bar" + WlrLayershell.layer: WlrLayer.Top color: Colors.transparent implicitHeight: Style.barHeight @@ -35,12 +35,11 @@ Variants { id: barBackground anchors.fill: parent - color: Niri.noFocus ? null : Colors.base gradient: Gradient { GradientStop { position: 0 - color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0.8 : 1) + color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, BarService.focusMode ? 1 : 0.8) Behavior on color { ColorAnimation { @@ -54,7 +53,7 @@ Variants { GradientStop { position: 1 - color: Qt.rgba(Colors.base.r, Colors.base.g, Colors.base.b, Niri.noFocus ? 0 : 1) + color: Qt.rgba(Colors.mSurface.r, Colors.mSurface.g, Colors.mSurface.b, BarService.focusMode ? 1 : 0) Behavior on color { ColorAnimation { @@ -81,42 +80,22 @@ Variants { leftMargin: 5 } - SymbolButton { - symbol: Icons.distro - buttonColor: Colors.distroColor - onClicked: { - PanelService.getPanel("controlCenterPanel")?.toggle(this) + UIconButton { + textOverride: "󰣇" + fontFamily: Fonts.nerd + baseSize: parent.height - Style.marginXXS * 2 + iconSize: Style.fontNerd + colorFg: Colors.distro + onClicked: () => { + BarService.toggleLeft(); } - onRightClicked: { - Quickshell.execDetached(["rofi", "-show", "drun"]); + onRightClicked: () => { + BarService.toggleRight(); } } - SymbolButton { - symbol: SettingsService.wifiEnabled ? Icons.wifiOn : Icons.wifiOff - buttonColor: Colors.rosewater - onClicked: { - PanelService.getPanel("wifiPanel")?.toggle(this) - } - } - - SymbolButton { - symbol: BluetoothService.enabled ? Icons.bluetoothOn : Icons.bluetoothOff - buttonColor: Colors.blue - onClicked: { - PanelService.getPanel("bluetoothPanel")?.toggle(this) - } - onRightClicked: { - Quickshell.execDetached(["blueman-manager"]); - } - } - - - Item { - width: 5 - } - Separator { + implicitWidth: Style.marginXL } Workspace { @@ -124,28 +103,17 @@ Variants { } Separator { - } - - Item { - width: 10 + implicitWidth: Style.marginXL } CavaBar { } - Item { - width: 10 - } - Separator { - } - - Item { - width: 10 + implicitWidth: Style.marginXL } FocusedWindow { - maxWidth: 400 } } @@ -176,83 +144,97 @@ Variants { rightMargin: 5 } - RowLayout { - id: monitorsLayout - visible: !SettingsService.showLyricsBar + Loader { + sourceComponent: LyricsService.showLyricsBar ? lyricsComponent : monitorsComponent + + Component { + id: monitorsComponent + + RowLayout { + id: monitorsLayout + + height: rightLayout.height + spacing: Style.marginM + Component.onCompleted: { + SystemStatService.registerComponent("BarMonitors"); + } + + NetworkSpeed { + } + + Separator { + } + + RecordIndicator { + } + + Ip { + } + + CpuTemp { + } + + MemUsage { + } + + CpuUsage { + } + + Battery { + } + + Brightness { + screen: modelData + } + + Volume { + } + + } - height: parent.height - NetworkSpeed { } - Separator { + Component { + id: lyricsComponent + + LyricsBar { + } + } - Item { - width: 10 - } - - RecordIndicator { - } - - Ip { - } - - CpuTemp { - } - - MemUsage { - } - - CpuUsage { - } - - Battery { - } - - Brightness { - screen: modelData - } - - Volume { - } - } - - LyricsBar { - id: lyricsBar - visible: SettingsService.showLyricsBar - width: 600 - } - - Item { - width: 5 } Separator { } - Item { - width: 5 - } + RowLayout { + height: rightLayout.height + spacing: Style.marginS - TrayExpander { - screen: modelData - } - - SymbolButton { - symbol: Caffeine.isInhibited ? Icons.idleInhibitorActivated : Icons.idleInhibitorDeactivated - buttonColor: Caffeine.isInhibited ? Colors.peach : Colors.yellow - onClicked: { - Caffeine.manualToggle(); + TrayExpander { + screen: modelData + baseSize: rightLayout.height - Style.marginXXS * 2 } - } - - SymbolButton { - symbol: Icons.powerMenu - buttonColor: Colors.red - onClicked: { - Quickshell.execDetached(["wlogout"]); + UIconButton { + iconName: Caffeine.isInhibited ? "mug-off" : "mug" + colorFg: Caffeine.isInhibited ? Colors.mOrange : Colors.mYellow + baseSize: rightLayout.height - Style.marginXXS * 2 + alwaysHover: Caffeine.isInhibited + onClicked: () => { + Caffeine.manualToggle(); + } } + + UIconButton { + iconName: "power" + colorFg: Colors.mRed + baseSize: rightLayout.height - Style.marginXXS * 2 + onClicked: () => { + BarService.toggleRight(); + } + } + } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuTemp.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuTemp.qml deleted file mode 100644 index 63bb532..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuTemp.qml +++ /dev/null @@ -1,28 +0,0 @@ -import QtQuick -import Quickshell.Io -import qs.Constants -import qs.Modules.Bar.Misc -import qs.Services - -MonitorItem { - symbol: SystemStatService.cpuTemp > 80 ? Icons.tempHigh : SystemStatService.cpuTemp > 50 ? Icons.tempMedium : Icons.tempLow - fillColor: Colors.yellow - critical: SystemStatService.cpuTemp > 80 - value: Math.round(SystemStatService.cpuTemp) - maxValue: 100 - textSuffix: "°C" - onClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["wezterm", "start", "--", "btop"]); - } - - Process { - id: action - - running: false - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuUsage.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuUsage.qml deleted file mode 100644 index f24d37c..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/CpuUsage.qml +++ /dev/null @@ -1,28 +0,0 @@ -import QtQuick -import Quickshell.Io -import qs.Constants -import qs.Modules.Bar.Misc -import qs.Services - -MonitorItem { - symbol: Icons.cpu - fillColor: Colors.teal - critical: SystemStatService.cpuUsage > 90 - value: Math.round(SystemStatService.cpuUsage) - maxValue: 100 - textSuffix: "%" - onClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["wezterm", "start", "--", "btop"]); - } - - Process { - id: action - - running: false - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml index 06571fe..3da8374 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Components/Separator.qml @@ -6,13 +6,14 @@ import qs.Constants Item { id: root - implicitHeight: parent.height + implicitHeight: Style.barHeight - Style.marginL * 2 + implicitWidth: Style.marginM Rectangle { anchors.centerIn: parent width: 1.5 - height: parent.height * 0.32 - color: Colors.text + height: parent.height + color: Colors.mOnSurface } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SystemTray.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/SystemTray.qml similarity index 95% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/SystemTray.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Components/SystemTray.qml index 8174d65..83ab26d 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SystemTray.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Components/SystemTray.qml @@ -5,7 +5,7 @@ import QtQuick.Controls import Quickshell import Quickshell.Services.SystemTray import Quickshell.Widgets -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Components import qs.Constants import qs.Services import qs.Utils @@ -107,7 +107,7 @@ Rectangle { trayMenu.item.menu = modelData.menu trayMenu.item.showAt(parent, menuX, menuY) } else { - Logger.log("Tray", "No menu available for", modelData.id, "or trayMenu not set") + Logger.d("Tray", "No menu available for", modelData.id, "or trayMenu not set") } } } @@ -150,7 +150,7 @@ Rectangle { Loader { id: trayMenu Component.onCompleted: { - setSource("../Misc/TrayMenu.qml", { + setSource("./TrayMenu.qml", { "screen": root.screen }) } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Time.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/Time.qml deleted file mode 100644 index b9ea3a6..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Time.qml +++ /dev/null @@ -1,22 +0,0 @@ -import QtQuick -import qs.Constants -import qs.Services - -Text { - text: TimeService.time + " | " + TimeService.dateString - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: Colors.primary - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.LeftButton) - PanelService.getPanel("calendarPanel")?.toggle(this) - else if (mouse.button === Qt.RightButton) - PanelService.getPanel("notificationHistoryPanel")?.toggle(this) - } - } -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/TrayMenu.qml b/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayMenu.qml similarity index 93% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/TrayMenu.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Components/TrayMenu.qml index adcec85..ea723b1 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/TrayMenu.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayMenu.qml @@ -5,7 +5,7 @@ import Quickshell import Quickshell.Widgets import qs.Constants import qs.Utils -import qs.Noctalia +import qs.Components PopupWindow { id: root @@ -86,8 +86,8 @@ PopupWindow { Rectangle { anchors.fill: parent - color: Colors.base - border.color: Colors.primary + color: Colors.mSurface + border.color: Colors.mPrimary border.width: 2 radius: Style.radiusM } @@ -126,7 +126,7 @@ PopupWindow { color: Colors.transparent property var subMenu: null - NDivider { + UDivider { anchors.centerIn: parent width: parent.width - (Style.marginM * 2) visible: modelData?.isSeparator ?? false @@ -134,7 +134,7 @@ PopupWindow { Rectangle { anchors.fill: parent - color: mouseArea.containsMouse ? Colors.primary : Colors.transparent + color: mouseArea.containsMouse ? Colors.mPrimary : Colors.transparent radius: Style.radiusS visible: !(modelData?.isSeparator ?? false) @@ -144,10 +144,10 @@ PopupWindow { anchors.rightMargin: Style.marginM spacing: Style.marginS - NText { + UText { id: text Layout.fillWidth: true - color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) : Color.mOnSurfaceVariant + color: (modelData?.enabled ?? true) ? (mouseArea.containsMouse ? Colors.mOnPrimary : Colors.mOnSurface) : Colors.mOnSurfaceVariant text: modelData?.text !== "" ? modelData?.text.replace(/[\n\r]+/g, ' ') : "..." pointSize: Style.fontSizeS verticalAlignment: Text.AlignVCenter @@ -162,12 +162,12 @@ PopupWindow { visible: (modelData?.icon ?? "") !== "" } - NIcon { - icon: modelData?.hasChildren ? "menu" : "" - pointSize: Style.fontSizeS + UIcon { + iconName: modelData?.hasChildren ? "menu" : "" + iconSize: Style.fontSizeS verticalAlignment: Text.AlignVCenter visible: modelData?.hasChildren ?? false - color: (mouseArea.containsMouse ? Color.mOnTertiary : Color.mOnSurface) + color: (mouseArea.containsMouse ? Colors.mOnPrimary : Colors.mOnSurface) } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SymbolButton.qml b/config/quickshell/.config/quickshell/Modules/Bar/Misc/SymbolButton.qml deleted file mode 100644 index 6ce7171..0000000 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/SymbolButton.qml +++ /dev/null @@ -1,64 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import qs.Constants - -Item { - id: root - - required property string symbol - property color buttonColor: Colors.distroColor - readonly property alias hovered: mouseArea.containsMouse - property real iconSize: Fonts.icon - property real radius: Style.radiusS - property bool disabledHover: false - - signal clicked() - signal rightClicked() - - implicitHeight: parent.height - implicitWidth: parent.height - - MouseArea { - id: mouseArea - - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - hoverEnabled: !disabledHover - acceptedButtons: Qt.LeftButton | Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) - root.rightClicked(); - else if (mouse.button === Qt.LeftButton) - root.clicked(); - } - } - - Text { - anchors.fill: parent - text: symbol - font.family: Fonts.nerd - font.pointSize: iconSize - font.bold: false - color: buttonColor - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - Rectangle { - anchors.fill: parent - color: parent.hovered ? buttonColor : Colors.transparent - opacity: 0.3 - radius: root.radius - - Behavior on color { - ColorAnimation { - duration: Style.animationNormal - easing.type: Easing.InOutCubic - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Battery.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Battery.qml similarity index 67% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Battery.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Battery.qml index e38414a..e05d95e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Battery.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Battery.qml @@ -1,20 +1,19 @@ import QtQuick import Quickshell.Services.UPower +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc -import qs.Services -MonitorItem { +UProgressExpand { readonly property var battery: UPower.displayDevice readonly property bool isReady: (battery && battery.ready && battery.isLaptopBattery && battery.isPresent) readonly property real percent: (isReady ? (battery.percentage * 100) : 0) readonly property bool charging: (isReady ? battery.state === UPowerDeviceState.Charging : false) property int lowBatteryThreshold: 20 - symbol: { - return charging ? Icons.charging : percent >= 80 ? Icons.battery100 : percent >= 60 ? Icons.battery75 : percent >= 40 ? Icons.battery50 : percent >= 20 ? Icons.battery25 : Icons.battery00; + iconName: { + return charging ? "battery-charging" : percent >= 80 ? "battery-4" : percent >= 60 ? "battery-3" : percent >= 40 ? "battery-2" : percent >= 20 ? "battery-1" : "battery-0"; } - fillColor: Colors.sapphire + fillColor: Colors.mSky value: percent critical: isReady && !charging && percent <= lowBatteryThreshold maxValue: 100 diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Brightness.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Brightness.qml similarity index 87% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Brightness.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Brightness.qml index 902a5bd..d8efeee 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Brightness.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Brightness.qml @@ -1,18 +1,18 @@ import QtQuick import Quickshell +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc import qs.Services -MonitorItem { +UProgressExpand { property ShellScreen screen: null function getMonitor() { return BrightnessService.getMonitorForScreen(screen) || null; } - symbol: Icons.brightness - fillColor: Colors.blue + iconName: "sun-filled" + fillColor: Colors.mBlue value: { const monitor = getMonitor(); return monitor ? Math.round(monitor.brightness * 100) : "N/A"; diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/CavaBar.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CavaBar.qml similarity index 90% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/CavaBar.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/CavaBar.qml index 2d2525b..07dc620 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/CavaBar.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CavaBar.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import qs.Constants -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Services import qs.Services import qs.Utils @@ -14,7 +14,7 @@ Item { property int mode: 0 implicitWidth: root.barWidth * CavaBarService.count + root.barSpacing * (CavaBarService.count - 1) - implicitHeight: parent.height - 10 + implicitHeight: Style.barHeight - Style.marginS * 2 RowLayout { anchors.fill: parent @@ -53,9 +53,7 @@ Item { acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton onClicked: (mouse) => { if (mouse.button === Qt.LeftButton) { - MusicManager.playPause(); - } else if (mouse.button === Qt.RightButton) { - SettingsService.showLyricsBar = !SettingsService.showLyricsBar; + MediaService.playPause(); } else if (mouse.button === Qt.MiddleButton) { mode = (mode + 1) % 3; if (mode === 0) { @@ -71,13 +69,15 @@ Item { CavaBarService.forceEnable = false; CavaBarService.forceDisable = true; } + } else if (mouse.button === Qt.RightButton) { + LyricsService.toggleLyricsBar(); } } onWheel: function(wheel) { if (wheel.angleDelta.y > 0) - MusicManager.previous(); + MediaService.previous(); else if (wheel.angleDelta.y < 0) - MusicManager.next(); + MediaService.next(); } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuTemp.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuTemp.qml new file mode 100644 index 0000000..3414132 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuTemp.qml @@ -0,0 +1,17 @@ +import QtQuick +import qs.Components +import qs.Constants +import qs.Modules.Bar.Services +import qs.Services + +UProgressExpand { + iconName: "temperature" + fillColor: Colors.mYellow + critical: SystemStatService.cpuTemp > 80 + value: Math.round(SystemStatService.cpuTemp) + maxValue: 100 + textSuffix: "°C" + onClicked: { + MonitorProcess.toggle(); + } +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuUsage.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuUsage.qml new file mode 100644 index 0000000..fdb3d29 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/CpuUsage.qml @@ -0,0 +1,20 @@ +import QtQuick +import Quickshell.Io +import qs.Components +import qs.Constants +import qs.Modules.Bar.Services +import qs.Services + +UProgressExpand { + // Quickshell.execDetached(["wezterm", "start", "--", "btop"]); + + iconName: "cpu" + fillColor: Colors.mCyan + critical: SystemStatService.cpuUsage > 90 + value: Math.round(SystemStatService.cpuUsage) + maxValue: 100 + textSuffix: "%" + onClicked: { + MonitorProcess.toggle(); + } +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/FocusedWindow.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/FocusedWindow.qml similarity index 87% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/FocusedWindow.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/FocusedWindow.qml index 2dcfbcc..cae6c6a 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/FocusedWindow.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/FocusedWindow.qml @@ -3,6 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts import Quickshell import Quickshell.Widgets +import qs.Components import qs.Constants import qs.Services import qs.Utils @@ -10,7 +11,7 @@ import qs.Utils Item { id: root - property real maxWidth: 250 + property real maxWidth: 320 property string fallbackIcon: "application-x-executable" function getAppIcon(appId) { @@ -23,24 +24,25 @@ Item { return iconResult; } catch (iconError) { - Logger.warn("FocusedWindow", "Error getting icon from CompositorService: " + iconError); + Logger.w("FocusedWindow", "Error getting icon from CompositorService: " + iconError); } } return ThemeIcons.iconFromName(root.fallbackIcon); } catch (e) { - Logger.warn("FocusedWindow", "Error in getAppIcon:", e); + Logger.w("FocusedWindow", "Error in getAppIcon:", e); return ThemeIcons.iconFromName(root.fallbackIcon); } } - implicitHeight: parent.height + implicitWidth: layout.implicitWidth + implicitHeight: Math.max(windowIcon.implicitHeight, windowTitle.implicitHeight) RowLayout { id: layout anchors.fill: parent spacing: 10 - visible: Niri.focusedWindowId !== -1 + visible: Niri.hasFocusedWindow Item { // Layout.alignment: Qt.AlignVCenter @@ -79,24 +81,20 @@ Item { height: parent.height anchors.verticalCenter: parent.verticalCenter - Text { + UText { id: windowTitle text: Niri.focusedWindowTitle anchors.verticalCenter: parent.verticalCenter - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary } - Text { + UText { text: Niri.focusedWindowTitle anchors.verticalCenter: parent.verticalCenter anchors.left: windowTitle.right anchors.leftMargin: titleContainer.scrollSpacing - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary visible: titleContainer.shouldScroll } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Ip.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Ip.qml similarity index 87% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Ip.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Ip.qml index 016e0db..33bae41 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Ip.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Ip.qml @@ -3,15 +3,14 @@ import QtQuick.Layouts import Quickshell import qs.Constants import qs.Services -import qs.Modules.Bar.Misc +import qs.Components -MonitorItem { - symbol: Icons.ip - fillColor: Colors.peach +UProgressExpand { + iconName: "world" + fillColor: Colors.mOrange value: 100 maxValue: 100 textValue: displayText - symbolSize: 18 property int displayIndex: 0 readonly property list displayTexts: [IpService.countryCode, IpService.ip, IpService.alias] diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/LyricsBar.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml similarity index 51% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/LyricsBar.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml index def33a3..3893de6 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/LyricsBar.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/LyricsBar.qml @@ -1,84 +1,76 @@ import QtQuick import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants -import qs.Noctalia import qs.Services Rectangle { - implicitHeight: parent.height radius: Style.radiusS - color: Colors.base - border.color: Colors.primary + color: Colors.mSurface + border.color: Colors.mPrimary border.width: Style.borderS - - Connections { - function onShowLyricsBarChanged() { - visible = SettingsService.showLyricsBar; - if (visible) - LyricsService.startSyncing(); - else - LyricsService.stopSyncing(); - } - - target: SettingsService + Component.onCompleted: { + LyricsService.startSyncing(); } + Component.onDestruction: { + LyricsService.stopSyncing(); + } + implicitHeight: Style.barHeight - Style.marginXS * 2 + implicitWidth: 600 RowLayout { anchors.fill: parent anchors.leftMargin: Style.marginM anchors.rightMargin: Style.marginM - spacing: Style.marginS + spacing: Style.marginXS Item { implicitWidth: parent.width - slowerButton.implicitWidth * 3 - parent.spacing * 3 - parent.anchors.leftMargin - parent.anchors.rightMargin Layout.fillHeight: true clip: true - NText { + UText { text: LyricsService.lyrics[LyricsService.currentIndex] || "" family: Fonts.sans - pointSize: Style.fontSizeS + pointSize: Style.fontSizeM maximumLineCount: 1 anchors.verticalCenter: parent.verticalCenter } } - NIconButton { + UIconButton { id: slowerButton - baseSize: 24 - colorBg: Color.transparent - colorBgHover: Colors.blue - colorFg: Colors.blue - icon: "rotate-2" + colorFg: Colors.mBlue + iconName: "rotate-2" + baseSize: parent.height - Style.marginXS * 2 + iconSize: Style.fontSizeM onClicked: { LyricsService.increaseOffset(); } } - NIconButton { + UIconButton { id: playPauseButton - baseSize: 24 - colorBg: Color.transparent - colorBgHover: Colors.yellow - colorFg: Colors.yellow - icon: "rotate-clockwise-2" + colorFg: Colors.mYellow + iconName: "rotate-clockwise-2" + baseSize: parent.height - Style.marginXS * 2 + iconSize: Style.fontSizeM onClicked: { LyricsService.decreaseOffset(); } } - NIconButton { + UIconButton { id: nextButton - baseSize: 24 - colorBg: Color.transparent - colorBgHover: Colors.green - colorFg: Colors.green - icon: "rotate-clockwise" + colorFg: Colors.mGreen + iconName: "rotate-clockwise" + baseSize: parent.height - Style.marginXS * 2 + iconSize: Style.fontSizeM onClicked: { LyricsService.resetOffset(); } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/MemUsage.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/MemUsage.qml similarity index 56% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/MemUsage.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/MemUsage.qml index 6fd9459..40b9aca 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/MemUsage.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/MemUsage.qml @@ -1,34 +1,23 @@ import QtQuick -import Quickshell.Io +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Services import qs.Services -MonitorItem { +UProgressExpand { property bool _showPercent: false - symbol: Icons.memory - fillColor: Colors.green + iconName: "database" + fillColor: Colors.mGreen critical: SystemStatService.memPercent > 90 value: Math.round(SystemStatService.memPercent) maxValue: 100 textValue: _showPercent ? SystemStatService.memPercent : SystemStatService.memGb textSuffix: _showPercent ? "%" : "GB" onClicked: { - if (action.running) { - action.signal(15); - return ; - } - action.exec(["wezterm", "start", "--", "btop"]); + MonitorProcess.toggle(); } onRightClicked: { _showPercent = !_showPercent; } - - Process { - id: action - - running: false - } - } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/NetworkSpeed.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/NetworkSpeed.qml similarity index 61% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/NetworkSpeed.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/NetworkSpeed.qml index 20e0442..61acd3e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/NetworkSpeed.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/NetworkSpeed.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants import qs.Services @@ -16,35 +17,30 @@ Item { anchors.bottom: parent.bottom spacing: 5 - Text { - text: Icons.download - font.pointSize: Fonts.icon - 3 - color: Colors.primary - Layout.leftMargin: 10 + UIcon { + iconName: "arrow-big-down-line-filled" } Text { text: SystemStatService.formatSpeed(SystemStatService.rxSpeed) - font.pointSize: Fonts.medium + font.pointSize: Style.fontSizeM font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary } Item { width: 5 } - Text { - text: Icons.upload - font.pointSize: Fonts.icon - 3 - color: Colors.primary + UIcon { + iconName: "arrow-big-up-line-filled" } Text { text: SystemStatService.formatSpeed(SystemStatService.txSpeed) - font.pointSize: Fonts.medium + font.pointSize: Style.fontSizeM font.family: Fonts.primary - color: Colors.primary + color: Colors.mPrimary } } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/RecordIndicator.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/RecordIndicator.qml similarity index 68% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/RecordIndicator.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/RecordIndicator.qml index 639b0c6..6bb5b5d 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/RecordIndicator.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/RecordIndicator.qml @@ -1,18 +1,20 @@ import QtQuick import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants import qs.Services Item { id: root - property color fillColor: Colors.red - property color _actualColor: Colors.red + property color fillColor: Colors.mRed + property color _actualColor: Colors.mRed + property bool _expand: mouseArea.containsMouse visible: RecordService.isRecording - implicitHeight: parent.height - implicitWidth: layout.width + 10 + implicitHeight: Math.max(symbolIcon.implicitHeight, textLabel.implicitHeight) + implicitWidth: height + expander.implicitWidth SequentialAnimation { id: blinkAnimation @@ -45,34 +47,36 @@ Item { anchors.bottom: parent.bottom spacing: 0 - Text { - text: Icons.record - font.pointSize: 18 - color: _actualColor + UIcon { + id: symbolIcon + + iconName: "capture-filled" + iconSize: Style.fontSizeM + 12 + color: root._actualColor + Layout.preferredWidth: parent.height + Layout.preferredHeight: parent.height } Item { id: expander - implicitWidth: mouseArea.containsMouse ? ipText.implicitWidth + 10 : 0 implicitHeight: parent.height + implicitWidth: root._expand ? textLabel.implicitWidth + 10 : 0 clip: true - Text { - id: ipText + UText { + id: textLabel - text: RecordService.recordingDisplay - font.pointSize: Fonts.medium - font.family: Fonts.primary - color: fillColor anchors.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: 5 + text: RecordService.recordingDisplay || "Recording" + color: root.fillColor } Behavior on implicitWidth { NumberAnimation { - duration: Style.animationFast + duration: Style.animationNormal easing.type: Easing.InOutCubic } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Modules/Time.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Time.qml new file mode 100644 index 0000000..784da9b --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Time.qml @@ -0,0 +1,10 @@ +import QtQuick +import qs.Constants +import qs.Services + +Text { + text: TimeService.time + " | " + TimeService.dateString + font.pointSize: Style.fontSizeM + font.family: Fonts.primary + color: Colors.mPrimary +} diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayExpander.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/TrayExpander.qml similarity index 70% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/TrayExpander.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/TrayExpander.qml index 206d456..9d03ee2 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/TrayExpander.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/TrayExpander.qml @@ -2,16 +2,18 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc +import qs.Modules.Bar.Components Item { id: root property ShellScreen screen + property int baseSize: Style.baseWidgetSize - implicitHeight: parent.height - implicitWidth: layout.implicitWidth + implicitWidth: baseSize + trayContainer.implicitWidth + implicitHeight: layout.implicitHeight RowLayout { id: layout @@ -20,9 +22,10 @@ Item { anchors.bottom: parent.bottom spacing: 0 - SymbolButton { - symbol: Icons.tray - buttonColor: Colors.green + UIconButton { + iconName: "layout-sidebar-right-expand-filled" + colorFg: Colors.mGreen + baseSize: root.baseSize disabledHover: true } @@ -41,7 +44,7 @@ Item { Behavior on implicitWidth { NumberAnimation { - duration: 200 + duration: Style.animationNormal easing.type: Easing.InOutCubic } diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Volume.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Volume.qml similarity index 62% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Volume.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Volume.qml index fa3400c..cc1da9f 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Volume.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Volume.qml @@ -1,12 +1,12 @@ import QtQuick import Quickshell +import qs.Components import qs.Constants -import qs.Modules.Bar.Misc import qs.Services -MonitorItem { - symbol: AudioService.muted ? Icons.volumeMuted : (AudioService.volume >= 0.5 ? Icons.volumeHigh : (AudioService.volume >= 0.2 ? Icons.volumeMedium : Icons.volumeLow)) - fillColor: Colors.lavender +UProgressExpand { + iconName: AudioService.muted ? "volume-3" : (AudioService.volume >= 0.5 ? "volume" : (AudioService.volume >= 0.2 ? "volume-2" : "volume-2")) + fillColor: Colors.mLavender value: Math.round(AudioService.volume * 100) maxValue: 100 textSuffix: "%" @@ -18,7 +18,7 @@ MonitorItem { AudioService.decreaseVolume(); } onClicked: { - AudioService.toggleMute(); + AudioService.setOutputMuted(!AudioService.muted); } onRightClicked: { Quickshell.execDetached(["sh", "-c", "pkill -x -n pwvucontrol || pwvucontrol"]); diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Components/Workspace.qml b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml similarity index 53% rename from config/quickshell/.config/quickshell/Modules/Bar/Components/Workspace.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml index d2b056f..e1be775 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Components/Workspace.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Modules/Workspace.qml @@ -1,4 +1,3 @@ -import Qt5Compat.GraphicalEffects import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -12,78 +11,59 @@ Item { id: root required property ShellScreen screen - property bool hovered: false property ListModel localWorkspaces property real masterProgress: 0 property bool effectsActive: false - property color effectColor: Colors.primary + property color effectColor: Colors.mPrimary property int horizontalPadding: 16 property int spacingBetweenPills: 8 - property bool isDestroying: false - - signal workspaceChanged(int workspaceId, color primaryColor) function triggerUnifiedWave() { - effectColor = Colors.primary; + effectColor = Colors.mPrimary; masterAnimation.restart(); } - function updateWorkspaceFocus() { - for (let i = 0; i < localWorkspaces.count; i++) { - const ws = localWorkspaces.get(i); - if (ws.isFocused === true) { - root.triggerUnifiedWave(); - root.workspaceChanged(ws.id, Colors.primary); - break; + function syncWorkspaces() { + let j = 0; + let focusChanged = false; + for (let i = 0; i < Niri.workspaces.count; i++) { + const ws = Niri.workspaces.get(i); + if (ws.output.toLowerCase() === screen.name.toLowerCase()) { + if (j < localWorkspaces.count) { + const existing = localWorkspaces.get(j); + if (ws.isFocused && !existing.isFocused) + focusChanged = true; + + localWorkspaces.setProperty(j, "id", ws.id); + localWorkspaces.setProperty(j, "idx", ws.idx); + localWorkspaces.setProperty(j, "isFocused", ws.isFocused); + localWorkspaces.setProperty(j, "isActive", ws.isActive); + localWorkspaces.setProperty(j, "isUrgent", ws.isUrgent); + localWorkspaces.setProperty(j, "isOccupied", ws.isOccupied); + } else { + localWorkspaces.append(ws); + if (ws.isFocused) + focusChanged = true; + + } + j++; } } + while (localWorkspaces.count > j)localWorkspaces.remove(localWorkspaces.count - 1) + if (focusChanged) + triggerUnifiedWave(); + } - implicitWidth: { - let total = 0; - for (let i = 0; i < localWorkspaces.count; i++) { - const ws = localWorkspaces.get(i); - if (ws.isFocused) - total += 44; - else if (ws.isActive) - total += 28; - else - total += 16; - } - total += Math.max(localWorkspaces.count - 1, 0) * spacingBetweenPills; - total += horizontalPadding * 2; - return total; - } - height: parent.height - Component.onCompleted: { - localWorkspaces.clear(); - for (let i = 0; i < WorkspaceManager.workspaces.count; i++) { - const ws = WorkspaceManager.workspaces.get(i); - if (ws.output.toLowerCase() === screen.name.toLowerCase()) - localWorkspaces.append(ws); - - } - workspaceRepeater.model = localWorkspaces; - updateWorkspaceFocus(); - } - Component.onDestruction: { - root.isDestroying = true; - } + implicitWidth: pillRow.implicitWidth + horizontalPadding * 2 + Component.onCompleted: syncWorkspaces() Connections { - function onWorkspacesChanged() { - localWorkspaces.clear(); - for (let i = 0; i < WorkspaceManager.workspaces.count; i++) { - const ws = WorkspaceManager.workspaces.get(i); - if (ws.output.toLowerCase() === screen.name.toLowerCase()) - localWorkspaces.append(ws); - - } - workspaceRepeater.model = localWorkspaces; - updateWorkspaceFocus(); + function onWorkspaceChanged() { + syncWorkspaces(); } - target: WorkspaceManager + target: Niri } SequentialAnimation { @@ -118,35 +98,11 @@ Item { } - Rectangle { - id: workspaceBackground - - width: parent.width - 15 - height: 26 - anchors.horizontalCenter: parent.horizontalCenter - anchors.verticalCenter: parent.verticalCenter - radius: 12 - color: Colors.transparent - layer.enabled: true - - layer.effect: DropShadow { - color: "black" - radius: 12 - samples: 24 - verticalOffset: 0 - horizontalOffset: 0 - opacity: 0.1 - } - - } - Row { id: pillRow spacing: spacingBetweenPills - anchors.verticalCenter: workspaceBackground.verticalCenter - width: root.width - horizontalPadding * 2 - x: horizontalPadding + anchors.centerIn: parent Repeater { id: workspaceRepeater @@ -172,23 +128,18 @@ Item { id: workspacePill anchors.fill: parent - radius: { - if (model.isFocused) - return 12; - else - return 6; - } + radius: height / 2 color: { if (model.isFocused) - return Colors.primary; + return Colors.mPrimary; if (model.isActive) - return Colors.overlay2; + return Colors.mOnSurfaceVariant; if (model.isUrgent) return Theme.error; - return Colors.surface2; + return Colors.mSurfaceVariant; } scale: model.isFocused ? 1 : 0.9 z: 0 @@ -199,28 +150,11 @@ Item { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: { - WorkspaceManager.switchToWorkspace(model.idx); + Niri.switchToWorkspace(model); } z: 20 hoverEnabled: true } - // Material 3-inspired smooth animation for width, height, scale, color, opacity, and radius - - Behavior on width { - NumberAnimation { - duration: 350 - easing.type: Easing.OutBack - } - - } - - Behavior on height { - NumberAnimation { - duration: 350 - easing.type: Easing.OutBack - } - - } Behavior on scale { NumberAnimation { @@ -246,14 +180,6 @@ Item { } - Behavior on radius { - NumberAnimation { - duration: 350 - easing.type: Easing.OutBack - } - - } - } Rectangle { @@ -262,8 +188,8 @@ Item { anchors.centerIn: workspacePillContainer width: workspacePillContainer.width + 18 * root.masterProgress height: workspacePillContainer.height + 18 * root.masterProgress - radius: width / 2 - color: "transparent" + radius: height / 2 + color: root.effectColor border.color: root.effectColor border.width: 2 + 6 * (1 - root.masterProgress) opacity: root.effectsActive && model.isFocused ? (1 - root.masterProgress) * 0.7 : 0 diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Misc/CavaBarService.qml b/config/quickshell/.config/quickshell/Modules/Bar/Services/CavaBarService.qml similarity index 93% rename from config/quickshell/.config/quickshell/Modules/Bar/Misc/CavaBarService.qml rename to config/quickshell/.config/quickshell/Modules/Bar/Services/CavaBarService.qml index c128cd4..2ed693e 100644 --- a/config/quickshell/.config/quickshell/Modules/Bar/Misc/CavaBarService.qml +++ b/config/quickshell/.config/quickshell/Modules/Bar/Services/CavaBarService.qml @@ -6,7 +6,7 @@ pragma Singleton Singleton { id: root - property int count: 6 + property int count: 7 property bool forceEnable: false property bool forceDisable: false property alias values: cavaProcess.values diff --git a/config/quickshell/.config/quickshell/Modules/Bar/Services/MonitorProcess.qml b/config/quickshell/.config/quickshell/Modules/Bar/Services/MonitorProcess.qml new file mode 100644 index 0000000..2f0dff7 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Bar/Services/MonitorProcess.qml @@ -0,0 +1,24 @@ +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton + +Singleton { + id: root + + function toggle() { + if (process.running) { + process.signal(15); + return ; + } + process.running = true; + } + + Process { + id: process + + running: false + command: ["wezterm", "start", "--", "btop"] + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml b/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml index 501615a..917a46a 100644 --- a/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml +++ b/config/quickshell/.config/quickshell/Modules/Misc/Corner.qml @@ -11,7 +11,7 @@ Shape { property int concaveHeight: 60 * size property int offsetX: -20 property int offsetY: -20 - property color fillColor: Colors.base + property color fillColor: Colors.mSurface property int arcRadius: 20 * size property var modelData: null // Position flags derived from position string diff --git a/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml b/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml index 98c59a3..a134600 100644 --- a/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml +++ b/config/quickshell/.config/quickshell/Modules/Misc/Corners.qml @@ -10,12 +10,11 @@ import qs.Services Scope { id: rootScope - property var shell property string namespace: "quickshell-corners" property int topMargin: 45 property int cornerHeight: 20 property real cornerSize: 1 - property real opacity: Niri.noFocus ? 0 : 1 + property real opacity: BarService.focusMode ? 1 : 0 Item { id: cornersRootItem @@ -26,7 +25,15 @@ Scope { model: Quickshell.screens Item { + id: screenItem + property var modelData + // property int leftOffset: BarService.leftOffset(modelData) + // property int rightOffset: BarService.rightOffset(modelData) + readonly property var leftBar: BarService.getLeftSidebar(modelData.name) + readonly property var rightBar: BarService.getRightSidebar(modelData.name) + property int leftOffset: leftBar?.isOpen ? leftBar.barWidth : 0 + property int rightOffset: rightBar?.isOpen ? rightBar.barWidth : 0 PanelWindow { id: fakeBar @@ -45,7 +52,7 @@ Scope { Rectangle { anchors.fill: parent - color: Colors.base + color: Colors.mSurface opacity: rootScope.opacity } @@ -59,9 +66,10 @@ Scope { color: "transparent" screen: modelData margins.top: topMargin + margins.left: screenItem.leftOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -87,9 +95,10 @@ Scope { color: "transparent" screen: modelData margins.top: topMargin + margins.right: screenItem.rightOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -114,9 +123,10 @@ Scope { anchors.left: true color: "transparent" screen: modelData + margins.left: screenItem.leftOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -141,9 +151,10 @@ Scope { anchors.right: true color: "transparent" screen: modelData + margins.right: screenItem.rightOffset WlrLayershell.exclusionMode: ExclusionMode.Ignore visible: true - WlrLayershell.layer: WlrLayer.Background + WlrLayershell.layer: WlrLayer.Top aboveWindows: false WlrLayershell.namespace: namespace implicitHeight: cornerHeight @@ -161,6 +172,22 @@ Scope { } + Behavior on leftOffset { + NumberAnimation { + duration: Style.animationSlow + easing.type: Easing.InOutCubic + } + + } + + Behavior on rightOffset { + NumberAnimation { + duration: Style.animationSlow + easing.type: Easing.InOutCubic + } + + } + } } @@ -169,7 +196,7 @@ Scope { Behavior on opacity { NumberAnimation { - duration: 1000 + duration: Style.animationSlowest easing.type: Easing.InOutCubic } diff --git a/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml b/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml index 606bb59..8b6c945 100644 --- a/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml +++ b/config/quickshell/.config/quickshell/Modules/Misc/Notification.qml @@ -1,439 +1,806 @@ import QtQuick +import QtQuick.Effects import QtQuick.Layouts import Quickshell import Quickshell.Services.Notifications import Quickshell.Wayland import Quickshell.Widgets -import qs.Constants -import qs.Noctalia -import qs.Services import qs.Utils +import qs.Services +import qs.Components +import qs.Constants // Simple notification popup - displays multiple notifications Variants { - // Force removal without animation as fallback - - // If no notification display activated in settings, then show them all - model: Quickshell.screens - - delegate: Loader { - id: root - - required property ShellScreen modelData - property real scaling: 1 - // Access the notification model from the service - property ListModel notificationModel: NotificationService.activeList - - // Loader is active when there are notifications - active: notificationModel.count > 0 || delayTimer.running - - // Keep loader active briefly after last notification to allow animations to complete - Timer { - id: delayTimer - - interval: Style.animationSlow + 200 // Animation duration + buffer - repeat: false - } - - // Start delay timer when last notification is removed - Connections { - function onCountChanged() { - if (notificationModel.count === 0 && root.active) - delayTimer.restart(); - - } - - target: notificationModel - } - - sourceComponent: PanelWindow { - readonly property string location: "top_right" - readonly property bool isTop: (location === "top") || (location.length >= 3 && location.substring(0, 3) === "top") - readonly property bool isBottom: (location === "bottom") || (location.length >= 6 && location.substring(0, 6) === "bottom") - readonly property bool isLeft: location.indexOf("_left") >= 0 - readonly property bool isRight: location.indexOf("_right") >= 0 - readonly property bool isCentered: (location === "top" || location === "bottom") - // Store connection for cleanup - property var animateConnection: null - - screen: modelData - WlrLayershell.namespace: "noctalia-notifications" - WlrLayershell.layer: WlrLayer.Overlay - color: Color.transparent - // Anchor selection based on location (window edges) - anchors.top: isTop - anchors.bottom: isBottom - anchors.left: isLeft - anchors.right: isRight - // Margins depending on bar position and chosen location - margins.top: Style.barHeight + Style.marginM - margins.bottom: 0 - margins.left: 0 - margins.right: Style.marginM - implicitWidth: 360 - implicitHeight: notificationStack.implicitHeight - WlrLayershell.exclusionMode: ExclusionMode.Ignore - // Connect to animation signal from service - Component.onCompleted: { - animateConnection = NotificationService.animateAndRemove.connect(function(notificationId) { - // Find the delegate by notification ID - var delegate = null; - if (notificationStack && notificationStack.children && notificationStack.children.length > 0) { - for (var i = 0; i < notificationStack.children.length; i++) { - var child = notificationStack.children[i]; - if (child && child.notificationId === notificationId) { - delegate = child; - break; - } - } - } - if (delegate && delegate.animateOut) - delegate.animateOut(); - else - NotificationService.dismissActiveNotification(notificationId); - }); - } - // Disconnect when destroyed to prevent memory leaks - Component.onDestruction: { - if (animateConnection) { - NotificationService.animateAndRemove.disconnect(animateConnection); - animateConnection = null; - } - } - - // Main notification container - ColumnLayout { - id: notificationStack - - // Anchor the stack inside the window based on chosen location - anchors.top: parent.isTop ? parent.top : undefined - anchors.bottom: parent.isBottom ? parent.bottom : undefined - anchors.left: parent.isLeft ? parent.left : undefined - anchors.right: parent.isRight ? parent.right : undefined - anchors.horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined - spacing: Style.marginS - width: 360 - visible: true - - // Multiple notifications display - Repeater { - model: notificationModel - - delegate: Rectangle { - id: card - - // Store the notification ID and data for reference - property string notificationId: model.id - property var notificationData: model - // Animation properties - property real scaleValue: 0.8 - property real opacityValue: 0 - property bool isRemoving: false - - // Animate out when being removed - function animateOut() { - if (isRemoving) - return ; - - // Prevent multiple animations - isRemoving = true; - scaleValue = 0.8; - opacityValue = 0; - } - - Layout.preferredWidth: 360 - Layout.preferredHeight: notificationLayout.implicitHeight + (Style.marginL * 2) - Layout.maximumHeight: Layout.preferredHeight - radius: Style.radiusL - border.color: Colors.overlay0 - border.width: Math.max(1, Style.borderS) - color: Color.mSurface - // Scale and fade-in animation - scale: scaleValue - opacity: opacityValue - // Animate in when the item is created - Component.onCompleted: { - scaleValue = 1; - opacityValue = 1; - } - // Check if this notification is being removed - onIsRemovingChanged: { - if (isRemoving) - removalTimer.start(); - - } - - // Optimized progress bar container - Rectangle { - id: progressBarContainer - - // Pre-calculate available width for the progress bar - readonly property real availableWidth: parent.width - (2 * parent.radius) - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - height: 2 - color: Color.transparent - - // Actual progress bar - centered and symmetric - Rectangle { - id: progressBar - - height: parent.height - // Center the bar and make it shrink symmetrically - x: parent.parent.radius + (parent.availableWidth * (1 - model.progress)) / 2 - width: parent.availableWidth * model.progress - color: { - if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) - return Colors.red; - else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) - return Colors.green; - else - return Colors.primary; - } - antialiasing: true - - // Smooth progress animation - Behavior on width { - enabled: !card.isRemoving // Disable during removal animation - - NumberAnimation { - duration: 100 // Quick but smooth - easing.type: Easing.Linear - } - - } - - Behavior on x { - enabled: !card.isRemoving - - NumberAnimation { - duration: 100 - easing.type: Easing.Linear - } - - } - - } - - } - - // Right-click to dismiss - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.RightButton - onClicked: (mouse) => { - if (mouse.button === Qt.RightButton) - animateOut(); - - } - } - - // Timer for delayed removal after animation - Timer { - id: removalTimer - - interval: Style.animationSlow - repeat: false - onTriggered: { - NotificationService.dismissActiveNotification(notificationId); - } - } - - ColumnLayout { - id: notificationLayout - - anchors.fill: parent - anchors.margins: Style.marginM - anchors.rightMargin: (Style.marginM + 32) // Leave space for close button - spacing: Style.marginM - - // Main content section - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - ColumnLayout { - // For real-time notification always show the original image - // as the cached version is most likely still processing. - NImageCircled { - Layout.preferredWidth: 40 - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignTop - Layout.topMargin: 30 - imagePath: model.originalImage || "" - borderColor: Color.transparent - borderWidth: 0 - fallbackIcon: "bell" - fallbackIconSize: 24 - } - - Item { - Layout.fillHeight: true - } - - } - - // Text content - ColumnLayout { - Layout.fillWidth: true - spacing: Style.marginS - - // Header section with app name and timestamp - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - Rectangle { - Layout.preferredWidth: 6 - Layout.preferredHeight: 6 - radius: Style.radiusXS - color: { - if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) - return Color.mError; - else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) - return Color.mOnSurface; - else - return Color.mPrimary; - } - Layout.alignment: Qt.AlignVCenter - } - - NText { - text: `${model.appName || I18n.tr("system.unknown-app")} · ${Time.formatRelativeTime(model.timestamp)}` - color: Color.mSecondary - pointSize: Style.fontSizeXS - family: Fonts.sans - } - - Item { - Layout.fillWidth: true - } - - } - - NText { - text: model.summary || I18n.tr("general.no-summary") - pointSize: Style.fontSizeL - font.weight: Style.fontWeightMedium - color: Color.mOnSurface - textFormat: Text.PlainText - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - Layout.fillWidth: true - maximumLineCount: 3 - elide: Text.ElideRight - family: Fonts.sans - visible: text.length > 0 - } - - NText { - text: model.body || "" - pointSize: Style.fontSizeM - color: Color.mOnSurface - textFormat: Text.PlainText - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - Layout.fillWidth: true - maximumLineCount: 5 - elide: Text.ElideRight - family: Fonts.sans - visible: text.length > 0 - } - - // Notification actions - Flow { - // Store the notification ID for access in button delegates - property string parentNotificationId: notificationId - // Parse actions from JSON string - property var parsedActions: { - try { - return model.actionsJson ? JSON.parse(model.actionsJson) : []; - } catch (e) { - return []; - } - } - - Layout.fillWidth: true - spacing: Style.marginS - Layout.topMargin: Style.marginM - flow: Flow.LeftToRight - layoutDirection: Qt.LeftToRight - visible: parsedActions.length > 0 - - Repeater { - model: parent.parsedActions - - delegate: NButton { - property var actionData: modelData - - text: { - var actionText = actionData.text || "Open"; - // If text contains comma, take the part after the comma (the display text) - if (actionText.includes(",")) - return actionText.split(",")[1] || actionText; - - return actionText; - } - fontFamily: Fonts.sans - fontSize: Style.fontSizeS - backgroundColor: Color.mPrimary - textColor: hovered ? Color.mOnTertiary : Color.mOnPrimary - hoverColor: Color.mTertiary - outlined: false - implicitHeight: 24 - onClicked: { - NotificationService.invokeAction(parent.parentNotificationId, actionData.identifier); - } - } - - } - - } - - } - - } - - } - - // Close button positioned absolutely - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.6 - anchors.top: parent.top - anchors.topMargin: Style.marginM - anchors.right: parent.right - anchors.rightMargin: Style.marginM - onClicked: { - animateOut(); - } - } - - // Animation behaviors - Behavior on scale { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutExpo - } - - } - - Behavior on opacity { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutQuad - } - - } - - } - - } - - } - - } - + // If no notification display activated in settings, then show them all + model: Quickshell.screens + + + delegate: Loader { + id: root + + // Migrate all settings value to constants + readonly property bool overlayLayer: true + readonly property string notificationPosition: "top_right" + readonly property bool barFloating: true + readonly property string barPosition: "top" + readonly property int barMarginVertical: 4 + readonly property int barMarginHorizontal: 4 + readonly property bool notificationIsFramed: false + readonly property int frameThickness: 8 + readonly property bool notificationCompact: false + readonly property double uiScaleRatio: 1 + readonly property bool animationsDisabled: false + readonly property bool clearDismissed: true + readonly property real backgroundOpacity: 1.0 + + required property ShellScreen modelData + + property ListModel notificationModel: NotificationService.activeList + + // Always create window (but with 0x0 dimensions when no notifications) + active: notificationModel.count > 0 || delayTimer.running + + // Keep loader active briefly after last notification to allow animations to complete + Timer { + id: delayTimer + interval: Style.animationSlow + 200 + repeat: false } + Connections { + target: notificationModel + function onCountChanged() { + if (notificationModel.count === 0 && root.active) { + delayTimer.restart(); + } + } + } + + sourceComponent: PanelWindow { + id: notifWindow + screen: modelData + + WlrLayershell.namespace: "noctalia-notifications-" + (screen?.name || "unknown") + WlrLayershell.layer: (root.overlayLayer) ? WlrLayer.Overlay : WlrLayer.Top + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + color: "transparent" + + // Make shadow area click-through, only notification content is clickable + mask: Region { + x: 0 + y: 0 + width: notifWindow.width + height: notifWindow.height + intersection: Intersection.Xor + + Region { + // The clickable content area is inset by shadowPadding from all edges + x: notifWindow.shadowPadding + y: notifWindow.shadowPadding + width: notifWindow.notifWidth + height: Math.max(0, notifWindow.height - notifWindow.shadowPadding * 2) + intersection: Intersection.Subtract + } + } + + // Parse location setting + readonly property string location: root.notificationPosition || "top_right" + readonly property bool isTop: location.startsWith("top") + readonly property bool isBottom: location.startsWith("bottom") + readonly property bool isLeft: location.endsWith("_left") + readonly property bool isRight: location.endsWith("_right") + readonly property bool isCentered: location === "top" || location === "bottom" + + readonly property string barPos: root.barPosition + readonly property bool isFloating: root.barFloating + readonly property real barHeight: Style.barHeight + + readonly property bool isFramed: root.notificationIsFramed + readonly property real frameThickness: root.frameThickness + + readonly property bool isCompact: root.notificationCompact + readonly property int notifWidth: Math.round((isCompact ? 320 : 440) * root.uiScaleRatio) + readonly property int shadowPadding: Style.shadowBlurMax + Style.marginL + + // Calculate bar and frame offsets for each edge separately + readonly property int barOffsetTop: { + if (barPos !== "top") + return isFramed ? frameThickness : 0; + const floatMarginV = isFloating ? Math.ceil(root.barMarginVertical) : 0; + return barHeight + floatMarginV; + } + + readonly property int barOffsetBottom: { + if (barPos !== "bottom") + return isFramed ? frameThickness : 0; + const floatMarginV = isFloating ? Math.ceil(root.barMarginVertical) : 0; + return barHeight + floatMarginV; + } + + readonly property int barOffsetLeft: { + if (barPos !== "left") + return isFramed ? frameThickness : 0; + const floatMarginH = isFloating ? Math.ceil(root.barMarginHorizontal) : 0; + return barHeight + floatMarginH; + } + + readonly property int barOffsetRight: { + if (barPos !== "right") + return isFramed ? frameThickness : 0; + const floatMarginH = isFloating ? Math.ceil(root.barMarginHorizontal) : 0; + return barHeight + floatMarginH; + } + + // Anchoring + anchors.top: isTop + anchors.bottom: isBottom + anchors.left: isLeft + anchors.right: isRight + + // Margins for PanelWindow - only apply bar offset for the specific edge where the bar is + margins.top: isTop ? barOffsetTop - shadowPadding + Style.marginM : 0 + margins.bottom: isBottom ? barOffsetBottom - shadowPadding : 0 + margins.left: isLeft ? barOffsetLeft - shadowPadding + Style.marginM : 0 + margins.right: isRight ? barOffsetRight - shadowPadding + Style.marginM : 0 + + implicitWidth: notifWidth + shadowPadding * 2 + implicitHeight: notificationStack.implicitHeight + Style.marginL + + property var animateConnection: null + + Component.onCompleted: { + animateConnection = function (notificationId) { + var delegate = null; + if (notificationRepeater) { + for (var i = 0; i < notificationRepeater.count; i++) { + var item = notificationRepeater.itemAt(i); + if (item?.notificationId === notificationId) { + delegate = item; + break; + } + } + } + + try { + if (delegate && typeof delegate.animateOut === "function" && !delegate.isRemoving) { + delegate.animateOut(); + } + } catch (e) { + // Service fallback if delegate is already invalid + NotificationService.dismissActiveNotification(notificationId); + } + }; + + NotificationService.animateAndRemove.connect(animateConnection); + } + + Component.onDestruction: { + if (animateConnection) { + NotificationService.animateAndRemove.disconnect(animateConnection); + animateConnection = null; + } + } + + ColumnLayout { + id: notificationStack + + anchors { + top: parent.isTop ? parent.top : undefined + bottom: parent.isBottom ? parent.bottom : undefined + left: parent.isLeft ? parent.left : undefined + right: parent.isRight ? parent.right : undefined + horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined + } + + spacing: -notifWindow.shadowPadding * 2 + Style.marginM + + Behavior on implicitHeight { + enabled: !root.animationDisabled + SpringAnimation { + spring: 2.0 + damping: 0.4 + epsilon: 0.01 + mass: 0.8 + } + } + + Repeater { + id: notificationRepeater + model: notificationModel + + delegate: Item { + id: card + + property string notificationId: model.id + property var notificationData: model + property bool isHovered: false + property bool isRemoving: false + + readonly property int animationDelay: index * 100 + readonly property int slideDistance: 300 + + Layout.preferredWidth: notifWidth + notifWindow.shadowPadding * 2 + Layout.preferredHeight: (notifWindow.isCompact ? compactContent.implicitHeight : notificationContent.implicitHeight) + Style.marginM * 2 + notifWindow.shadowPadding * 2 + Layout.maximumHeight: Layout.preferredHeight + + // Animation properties + property real scaleValue: 0.8 + property real opacityValue: 0.0 + property real slideOffset: 0 + property real swipeOffset: 0 + property real swipeOffsetY: 0 + property real pressGlobalX: 0 + property real pressGlobalY: 0 + property bool isSwiping: false + property bool suppressClick: false + readonly property bool useVerticalSwipe: notifWindow.location === "bottom" || notifWindow.location === "top" + readonly property real swipeStartThreshold: Math.round(18 * root.uiScaleRatio) + readonly property real swipeDismissThreshold: Math.max(110, cardBackground.width * 0.32) + readonly property real verticalSwipeDismissThreshold: Math.max(70, cardBackground.height * 0.35) + + scale: scaleValue + opacity: opacityValue + transform: Translate { + x: card.swipeOffset + y: card.slideOffset + card.swipeOffsetY + } + + readonly property real slideInOffset: notifWindow.isTop ? -slideDistance : slideDistance + readonly property real slideOutOffset: slideInOffset + + function clampSwipeDelta(deltaX) { + if (notifWindow.isRight) + return Math.max(0, deltaX); + if (notifWindow.isLeft) + return Math.min(0, deltaX); + return deltaX; + } + + function clampVerticalSwipeDelta(deltaY) { + if (notifWindow.isBottom) + return Math.max(0, deltaY); + if (notifWindow.isTop) + return Math.min(0, deltaY); + return deltaY; + } + + // Animation setup + function triggerEntryAnimation() { + animInDelayTimer.stop(); + removalTimer.stop(); + resumeTimer.stop(); + isRemoving = false; + isHovered = false; + isSwiping = false; + swipeOffset = 0; + swipeOffsetY = 0; + if (root.animationDisabled) { + slideOffset = 0; + scaleValue = 1.0; + opacityValue = 1.0; + return; + } + + slideOffset = slideInOffset; + scaleValue = 0.8; + opacityValue = 0.0; + animInDelayTimer.interval = animationDelay; + animInDelayTimer.start(); + } + + Component.onCompleted: triggerEntryAnimation() + + onNotificationIdChanged: triggerEntryAnimation() + + Timer { + id: animInDelayTimer + interval: 0 + repeat: false + onTriggered: { + if (card.isRemoving) + return; + slideOffset = 0; + scaleValue = 1.0; + opacityValue = 1.0; + } + } + + function animateOut() { + if (isRemoving) + return; + animInDelayTimer.stop(); + resumeTimer.stop(); + isRemoving = true; + isSwiping = false; + swipeOffset = 0; + swipeOffsetY = 0; + if (!root.animationDisabled) { + slideOffset = slideOutOffset; + scaleValue = 0.8; + opacityValue = 0.0; + } + } + + function dismissBySwipe() { + if (isRemoving) + return; + animInDelayTimer.stop(); + resumeTimer.stop(); + isRemoving = true; + isSwiping = false; + if (!root.animationDisabled) { + if (useVerticalSwipe) { + swipeOffset = 0; + swipeOffsetY = swipeOffsetY >= 0 ? cardBackground.height + Style.marginXL : -cardBackground.height - Style.marginXL; + } else { + swipeOffset = swipeOffset >= 0 ? cardBackground.width + Style.marginXL : -cardBackground.width - Style.marginXL; + swipeOffsetY = 0; + } + scaleValue = 0.8; + opacityValue = 0.0; + } else { + swipeOffset = 0; + swipeOffsetY = 0; + } + } + + function runAction(actionId, isDismissed) { + if (!isDismissed) { + NotificationService.focusSenderWindow(model.appName); + NotificationService.invokeActionAndSuppressClose(notificationId, actionId); + } else if (root.clearDismissed) { + NotificationService.removeFromHistory(notificationId); + } + card.animateOut(); + } + + Timer { + id: removalTimer + interval: Style.animationSlow + repeat: false + onTriggered: { + NotificationService.dismissActiveNotification(notificationId); + } + } + + onIsRemovingChanged: { + if (isRemoving) { + removalTimer.start(); + } + } + + Behavior on scale { + enabled: !root.animationDisabled + SpringAnimation { + spring: 3 + damping: 0.4 + epsilon: 0.01 + mass: 0.8 + } + } + + Behavior on opacity { + enabled: !root.animationDisabled + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + } + + Behavior on slideOffset { + enabled: !root.animationDisabled + SpringAnimation { + spring: 2.5 + damping: 0.3 + epsilon: 0.01 + mass: 0.6 + } + } + + Behavior on swipeOffset { + enabled: !root.animationDisabled && !card.isSwiping + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + + Behavior on swipeOffsetY { + enabled: !root.animationDisabled && !card.isSwiping + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + + // Sub item with the right dimensions, really usefull for the + // HoverHandler: card items are overlapping because of the + // negative spacing of notificationStack. + Item { + id: displayedCard + + anchors.fill: parent + anchors.margins: notifWindow.shadowPadding + + HoverHandler { + onHoveredChanged: { + isHovered = hovered; + if (isHovered) { + resumeTimer.stop(); + NotificationService.pauseTimeout(notificationId); + } else { + resumeTimer.start(); + } + } + } + + Timer { + id: resumeTimer + interval: 50 + repeat: false + onTriggered: { + if (!isHovered) { + NotificationService.resumeTimeout(notificationId); + } + } + } + + // Right-click to dismiss + MouseArea { + id: cardDragArea + anchors.fill: cardBackground + acceptedButtons: Qt.LeftButton | Qt.RightButton + hoverEnabled: true + onPressed: mouse => { + if (mouse.button === Qt.LeftButton) { + const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y); + card.pressGlobalX = globalPoint.x; + card.pressGlobalY = globalPoint.y; + card.isSwiping = false; + card.suppressClick = false; + } + } + onPositionChanged: mouse => { + if (!(mouse.buttons & Qt.LeftButton) || card.isRemoving) + return; + const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y); + const rawDeltaX = globalPoint.x - card.pressGlobalX; + const rawDeltaY = globalPoint.y - card.pressGlobalY; + const deltaX = card.clampSwipeDelta(rawDeltaX); + const deltaY = card.clampVerticalSwipeDelta(rawDeltaY); + if (!card.isSwiping) { + if (card.useVerticalSwipe) { + if (Math.abs(deltaY) < card.swipeStartThreshold) + return; + card.isSwiping = true; + } else { + if (Math.abs(deltaX) < card.swipeStartThreshold) + return; + card.isSwiping = true; + } + } + if (card.useVerticalSwipe) { + card.swipeOffset = 0; + card.swipeOffsetY = deltaY; + } else { + card.swipeOffset = deltaX; + card.swipeOffsetY = 0; + } + } + onReleased: mouse => { + if (mouse.button === Qt.RightButton) { + card.animateOut(); + if (root.clearDismissed) { + NotificationService.removeFromHistory(notificationId); + } + return; + } + + if (mouse.button !== Qt.LeftButton) + return; + + if (card.isSwiping) { + const dismissDistance = card.useVerticalSwipe ? Math.abs(card.swipeOffsetY) : Math.abs(card.swipeOffset); + const threshold = card.useVerticalSwipe ? card.verticalSwipeDismissThreshold : card.swipeDismissThreshold; + if (dismissDistance >= threshold) { + card.dismissBySwipe(); + if (root.clearDismissed) { + NotificationService.removeFromHistory(notificationId); + } + } else { + card.swipeOffset = 0; + card.swipeOffsetY = 0; + } + card.suppressClick = true; + card.isSwiping = false; + return; + } + + if (card.suppressClick) + return; + + var actions = model.actionsJson ? JSON.parse(model.actionsJson) : []; + var hasDefault = actions.some(function (a) { + return a.identifier === "default"; + }); + if (hasDefault) { + card.runAction("default", false); + } else { + NotificationService.focusSenderWindow(model.appName); + card.animateOut(); + } + } + onCanceled: { + card.isSwiping = false; + card.swipeOffset = 0; + card.swipeOffsetY = 0; + } + } + + // Background with border + Rectangle { + id: cardBackground + anchors.fill: parent + radius: Style.radiusL + border.color: Qt.alpha(Colors.mOutline, root.backgroundOpacity || 1.0) + border.width: Style.borderS + color: Qt.alpha(Colors.mSurface, root.backgroundOpacity || 1.0) + + // Progress bar + Rectangle { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 2 + color: "transparent" + + Rectangle { + id: progressBar + readonly property real progressWidth: cardBackground.width - (2 * cardBackground.radius) + height: parent.height + x: cardBackground.radius + (progressWidth * (1 - model.progress)) / 2 + width: progressWidth * model.progress + + color: { + var baseColor = model.urgency === 2 ? Colors.mError : model.urgency === 0 ? Colors.mOnSurface : Colors.mPrimary; + return Qt.alpha(baseColor, root.backgroundOpacity || 1.0); + } + + antialiasing: true + + Behavior on width { + enabled: !card.isRemoving + NumberAnimation { + duration: 100 + easing.type: Easing.Linear + } + } + + Behavior on x { + enabled: !card.isRemoving + NumberAnimation { + duration: 100 + easing.type: Easing.Linear + } + } + } + } + } + + UDropShadow { + anchors.fill: cardBackground + source: cardBackground + autoPaddingEnabled: true + } + + // Content + ColumnLayout { + id: notificationContent + visible: !notifWindow.isCompact + anchors.fill: cardBackground + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginL + Layout.leftMargin: Style.marginM + Layout.rightMargin: Style.marginM + Layout.topMargin: Style.marginM + Layout.bottomMargin: Style.marginM + + UImageRounded { + Layout.preferredWidth: Math.round(40 * root.uiScaleRatio) + Layout.preferredHeight: Math.round(40 * root.uiScaleRatio) + Layout.alignment: Qt.AlignVCenter + radius: Math.min(Style.radiusL, Layout.preferredWidth / 2) + imagePath: model.originalImage || "" + borderColor: "transparent" + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginS + + // Header with urgency indicator + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Rectangle { + Layout.preferredWidth: 6 + Layout.preferredHeight: 6 + Layout.alignment: Qt.AlignVCenter + radius: Style.radiusXS + color: model.urgency === 2 ? Colors.mError : model.urgency === 0 ? Colors.mOnSurface : Colors.mPrimary + } + + UText { + text: model.appName || "Unknown App" + pointSize: Style.fontSizeXS + font.weight: Style.fontWeightBold + color: Colors.mPrimary + } + + UText { + textFormat: Text.PlainText + text: " " + Time.formatRelativeTime(model.timestamp) + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignBottom + } + + Item { + Layout.fillWidth: true + } + } + + UText { + text: model.summary || "No summary" + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Colors.mOnSurface + textFormat: Text.StyledText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + maximumLineCount: 3 + elide: Text.ElideRight + visible: text.length > 0 + Layout.fillWidth: true + Layout.rightMargin: Style.marginM + } + + UText { + text: model.body || "" + pointSize: Style.fontSizeM + color: Colors.mOnSurface + textFormat: Text.StyledText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + + maximumLineCount: 5 + elide: Text.ElideRight + visible: text.length > 0 + Layout.fillWidth: true + Layout.rightMargin: Style.marginXL + } + + // Actions + Flow { + Layout.fillWidth: true + spacing: Style.marginS + Layout.topMargin: Style.marginM + flow: Flow.LeftToRight + + property string parentNotificationId: notificationId + property var parsedActions: { + try { + return model.actionsJson ? JSON.parse(model.actionsJson) : []; + } catch (e) { + return []; + } + } + visible: parsedActions.length > 0 + + Repeater { + model: parent.parsedActions + + delegate: UButton { + property var actionData: modelData + + text: { + var actionText = actionData.text || "Open"; + if (actionText.includes(",")) { + return actionText.split(",")[1] || actionText; + } + return actionText; + } + fontSize: Style.fontSizeS + backgroundColor: Colors.mPrimary + textColor: hovered ? Colors.mOnHover : Colors.mOnPrimary + hoverColor: Colors.mHover + outlined: false + implicitHeight: 24 + onClicked: { + card.runAction(actionData.identifier, false); + } + } + } + } + } + } + } + + // Close button + UIconButton { + visible: !notifWindow.isCompact + iconName: "close" + baseSize: Style.baseWidgetSize * 0.6 + anchors.top: cardBackground.top + anchors.topMargin: Style.marginXL + anchors.right: cardBackground.right + anchors.rightMargin: Style.marginXL + + onClicked: { + card.runAction("", true); + } + } + + // Compact content + RowLayout { + id: compactContent + visible: notifWindow.isCompact + anchors.fill: cardBackground + anchors.margins: Style.marginM + spacing: Style.marginS + + UImageRounded { + Layout.preferredWidth: Math.round(24 * root.uiScaleRatio) + Layout.preferredHeight: Math.round(24 * root.uiScaleRatio) + Layout.alignment: Qt.AlignVCenter + radius: Style.radiusXS + imagePath: model.originalImage || "" + borderColor: "transparent" + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 16 + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + UText { + text: "No summary" + pointSize: Style.fontSizeM + font.weight: Style.fontWeightMedium + color: Colors.mOnSurface + textFormat: Text.StyledText + maximumLineCount: 1 + elide: Text.ElideRight + Layout.fillWidth: true + } + + UText { + visible: model.body && model.body.length > 0 + Layout.fillWidth: true + text: model.body || "" + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + textFormat: Text.StyledText + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + } + } + } + } + } + } + } + } + } } diff --git a/config/quickshell/.config/quickshell/Modules/Panel/BluetoothPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/BluetoothPanel.qml deleted file mode 100644 index 39722b3..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/BluetoothPanel.qml +++ /dev/null @@ -1,254 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Bluetooth -import Quickshell.Wayland -import qs.Constants -import qs.Modules.Panel.Misc -import qs.Noctalia -import qs.Services - -NPanel { - id: root - - preferredWidth: 380 - preferredHeight: 500 - - panelContent: Rectangle { - color: Color.transparent - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - // HEADER - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - NIcon { - icon: "bluetooth" - pointSize: Style.fontSizeXXL - color: Color.mPrimary - } - - NText { - text: "Bluetooth" - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Color.mOnSurface - Layout.fillWidth: true - } - - NToggle { - id: bluetoothSwitch - - checked: BluetoothService.enabled - onToggled: (checked) => { - return BluetoothService.setBluetoothEnabled(checked); - } - baseSize: Style.baseWidgetSize * 0.65 - } - - NIconButton { - enabled: BluetoothService.enabled - icon: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "refresh" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - if (BluetoothService.adapter) - BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering; - - } - colorFg: Colors.green - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.green - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - root.close(); - } - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - } - - NDivider { - Layout.fillWidth: true - } - - Rectangle { - visible: !(BluetoothService.adapter && BluetoothService.adapter.enabled) - Layout.fillWidth: true - Layout.fillHeight: true - color: Color.transparent - - // Center the content within this rectangle - ColumnLayout { - anchors.centerIn: parent - spacing: Style.marginM - - NIcon { - icon: "bluetooth-off" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Bluetooth is turned off" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Enable Bluetooth" - pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - } - - } - - NScrollView { - visible: BluetoothService.adapter && BluetoothService.adapter.enabled - Layout.fillWidth: true - Layout.fillHeight: true - horizontalPolicy: ScrollBar.AlwaysOff - verticalPolicy: ScrollBar.AsNeeded - clip: true - contentWidth: availableWidth - - ColumnLayout { - width: parent.width - spacing: Style.marginM - - // Connected devices - BluetoothDevicesList { - property var items: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return []; - - var filtered = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.blocked && dev.connected; - }); - return BluetoothService.sortDevices(filtered); - } - - label: "Connected Devices" - model: items - visible: items.length > 0 - Layout.fillWidth: true - } - - // Known devices - BluetoothDevicesList { - property var items: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return []; - - var filtered = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.blocked && !dev.connected && (dev.paired || dev.trusted); - }); - return BluetoothService.sortDevices(filtered); - } - - label: "Known Devices" - tooltipText: "Connect/Disconnect Devices" - model: items - visible: items.length > 0 - Layout.fillWidth: true - } - - // Available devices - BluetoothDevicesList { - property var items: { - if (!BluetoothService.adapter || !Bluetooth.devices) - return []; - - var filtered = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.blocked && !dev.paired && !dev.trusted; - }); - return BluetoothService.sortDevices(filtered); - } - - label: "Available Devices" - model: items - visible: items.length > 0 - Layout.fillWidth: true - } - - // Fallback - No devices, scanning - ColumnLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginM - visible: { - if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) - return false; - - var availableCount = Bluetooth.devices.values.filter((dev) => { - return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); - }).length; - return (availableCount === 0); - } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginXS - - NIcon { - icon: "refresh" - pointSize: Style.fontSizeXXL * 1.5 - color: Color.mPrimary - - RotationAnimation on rotation { - running: true - loops: Animation.Infinite - from: 0 - to: 360 - duration: Style.animationSlow * 4 - } - - } - - NText { - text: "Scanning..." - pointSize: Style.fontSizeL - color: Color.mOnSurface - } - - } - - NText { - text: "Pairing Mode" - pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - } - - Item { - Layout.fillHeight: true - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/CalendarPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/CalendarPanel.qml deleted file mode 100644 index d34e69b..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/CalendarPanel.qml +++ /dev/null @@ -1,526 +0,0 @@ -import Qt5Compat.GraphicalEffects -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Wayland -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -NPanel { - id: root - - preferredWidth: 400 - preferredHeight: 520 - - panelContent: ColumnLayout { - id: content - - readonly property int firstDayOfWeek: Qt.locale().firstDayOfWeek - property bool isCurrentMonth: checkIsCurrentMonth() - readonly property bool weatherReady: (LocationService.data.weather !== null) - - function checkIsCurrentMonth() { - return (Time.date.getMonth() === grid.month) && (Time.date.getFullYear() === grid.year); - } - - function getISOWeekNumber(date) { - const target = new Date(date.getTime()); - target.setHours(0, 0, 0, 0); - const dayOfWeek = target.getDay() || 7; - target.setDate(target.getDate() + 4 - dayOfWeek); - const yearStart = new Date(target.getFullYear(), 0, 1); - const weekNumber = Math.ceil(((target - yearStart) / 8.64e+07 + 1) / 7); - return weekNumber; - } - - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - Connections { - function onDateChanged() { - isCurrentMonth = checkIsCurrentMonth(); - } - - target: Time - } - - // Combined blue banner with date/time and weather summary - NBox { - Layout.fillWidth: true - Layout.preferredHeight: blueColumn.implicitHeight + Style.marginM * 2 - - ColumnLayout { - id: blueColumn - - anchors.fill: parent - anchors.margins: Style.marginM - spacing: 0 - - // Combined layout for weather icon, date, and weather text - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: 60 - spacing: Style.marginS - - // Weather icon and temperature - ColumnLayout { - Layout.alignment: Qt.AlignVCenter - spacing: Style.marginXXS - - NIcon { - Layout.alignment: Qt.AlignHCenter - icon: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode) : "cloud" - pointSize: Style.fontSizeXXL - color: Colors.text - } - - NText { - Layout.alignment: Qt.AlignHCenter - text: { - if (!weatherReady) - return ""; - - var temp = LocationService.data.weather.current_weather.temperature; - var suffix = "C"; - temp = Math.round(temp); - return `${temp}°${suffix}`; - } - pointSize: Style.fontSizeM - font.weight: Style.fontWeightBold - color: Colors.text - } - - } - - // Today day number - NText { - visible: content.isCurrentMonth - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - text: Time.date.getDate() - pointSize: Style.fontSizeXXXL * 1.5 - font.weight: Style.fontWeightBold - color: Colors.text - } - - Item { - visible: !content.isCurrentMonth - } - - // Month, year, location - ColumnLayout { - Layout.fillWidth: false - Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft - spacing: -Style.marginXS - - RowLayout { - spacing: 0 - - NText { - text: Qt.locale().monthName(grid.month, Locale.LongFormat).toUpperCase() - pointSize: Style.fontSizeXL * 1.2 - font.weight: Style.fontWeightBold - color: Colors.text - Layout.alignment: Qt.AlignBaseline - Layout.maximumWidth: 150 - elide: Text.ElideRight - } - - NText { - text: ` ${grid.year}` - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Qt.alpha(Colors.text, 0.7) - Layout.alignment: Qt.AlignBaseline - } - - } - - RowLayout { - spacing: 0 - - NText { - text: { - if (!weatherReady) - return "Weather unavailable"; - - const chunks = LocationService.data.name.split(","); - return chunks[0]; - } - pointSize: Style.fontSizeM - font.weight: Style.fontWeightMedium - color: Colors.text - Layout.maximumWidth: 150 - elide: Text.ElideRight - } - - NText { - text: weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : "" - pointSize: Style.fontSizeXS - font.weight: Style.fontWeightMedium - color: Qt.alpha(Colors.text, 0.7) - } - - } - - } - - // Spacer between date and clock - Item { - Layout.fillWidth: true - } - - // Digital clock with circular progress - Item { - width: Style.fontSizeXXXL * 1.9 - height: Style.fontSizeXXXL * 1.9 - Layout.alignment: Qt.AlignVCenter - - // Seconds circular progress - Canvas { - id: secondsProgress - - property real progress: Time.date.getSeconds() / 60 - - anchors.fill: parent - onProgressChanged: requestPaint() - onPaint: { - var ctx = getContext("2d"); - var centerX = width / 2; - var centerY = height / 2; - var radius = Math.min(width, height) / 2 - 3; - ctx.reset(); - // Background circle - ctx.beginPath(); - ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI); - ctx.lineWidth = 2.5; - ctx.strokeStyle = Qt.alpha(Colors.text, 0.15); - ctx.stroke(); - // Progress arc - ctx.beginPath(); - ctx.arc(centerX, centerY, radius, -Math.PI / 2, -Math.PI / 2 + progress * 2 * Math.PI); - ctx.lineWidth = 2.5; - ctx.strokeStyle = Colors.text; - ctx.lineCap = "round"; - ctx.stroke(); - } - - Connections { - function onDateChanged() { - secondsProgress.progress = Time.date.getSeconds() / 60; - } - - target: Time - } - - } - - // Digital clock - ColumnLayout { - anchors.centerIn: parent - spacing: -Style.marginXXS - - NText { - text: { - var t = Qt.locale().toString(new Date(), "HH"); - return t.split(" ")[0]; - } - pointSize: Style.fontSizeXS - font.weight: Style.fontWeightBold - color: Colors.text - family: Fonts.sans - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: Qt.formatTime(Time.date, "mm") - pointSize: Style.fontSizeXXS - font.weight: Style.fontWeightBold - color: Colors.text - family: Fonts.sans - Layout.alignment: Qt.AlignHCenter - } - - } - - } - - } - - } - - } - - // 6-day forecast (outside blue banner) - RowLayout { - visible: weatherReady - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginL - - Repeater { - model: weatherReady ? Math.min(6, LocationService.data.weather.daily.time.length) : 0 - - delegate: ColumnLayout { - Layout.preferredWidth: 0 - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginS - - NText { - text: { - var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/")); - return Qt.locale().toString(weatherDate, "ddd"); - } - color: Color.mOnSurfaceVariant - pointSize: Style.fontSizeM - font.weight: Style.fontWeightMedium - Layout.alignment: Qt.AlignHCenter - } - - NIcon { - Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter - icon: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index]) - pointSize: Style.fontSizeXXL * 1.5 - color: LocationService.weatherColorFromCode(LocationService.data.weather.daily.weathercode[index]) - } - - NText { - Layout.alignment: Qt.AlignHCenter - text: { - var max = LocationService.data.weather.daily.temperature_2m_max[index]; - var min = LocationService.data.weather.daily.temperature_2m_min[index]; - max = Math.round(max); - min = Math.round(min); - return `${max}°/${min}°`; - } - pointSize: Style.fontSizeXS - color: Colors.text - font.weight: Style.fontWeightMedium - } - - } - - } - - } - - // Loading indicator for weather - RowLayout { - visible: !weatherReady - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - - NBusyIndicator { - } - - } - - // Spacer - Item { - } - - // Navigation and divider - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - NDivider { - Layout.fillWidth: true - } - - NIconButton { - icon: "chevron-left" - colorBg: Color.transparent - colorBorder: Color.transparent - colorBorderHover: Color.transparent - onClicked: { - let newDate = new Date(grid.year, grid.month - 1, 1); - grid.year = newDate.getFullYear(); - grid.month = newDate.getMonth(); - content.isCurrentMonth = content.checkIsCurrentMonth(); - } - } - - NIconButton { - icon: "calendar" - colorBg: Color.transparent - colorBorder: Color.transparent - colorBorderHover: Color.transparent - onClicked: { - grid.month = Time.date.getMonth(); - grid.year = Time.date.getFullYear(); - content.isCurrentMonth = true; - } - } - - NIconButton { - icon: "chevron-right" - colorBg: Color.transparent - colorBorder: Color.transparent - colorBorderHover: Color.transparent - onClicked: { - let newDate = new Date(grid.year, grid.month + 1, 1); - grid.year = newDate.getFullYear(); - grid.month = newDate.getMonth(); - content.isCurrentMonth = content.checkIsCurrentMonth(); - } - } - - } - - // Names of days of the week - RowLayout { - Layout.fillWidth: true - spacing: 0 - - Item { - Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 - } - - GridLayout { - Layout.fillWidth: true - columns: 7 - rows: 1 - columnSpacing: 0 - rowSpacing: 0 - - Repeater { - model: 7 - - Item { - Layout.fillWidth: true - Layout.preferredHeight: Style.baseWidgetSize * 0.6 - - NText { - anchors.centerIn: parent - text: { - let dayIndex = (content.firstDayOfWeek + index) % 7; - const dayNames = ["S", "M", "T", "W", "T", "F", "S"]; - return dayNames[dayIndex]; - } - color: Color.mPrimary - pointSize: Style.fontSizeS - font.weight: Style.fontWeightBold - horizontalAlignment: Text.AlignHCenter - } - - } - - } - - } - - } - - // Grid with weeks and days - RowLayout { - Layout.fillWidth: true - Layout.fillHeight: true - spacing: 0 - - // Column of week numbers - ColumnLayout { - Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 - Layout.fillHeight: true - spacing: 0 - - Repeater { - model: 6 - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - NText { - anchors.centerIn: parent - color: Color.mOutline - pointSize: Style.fontSizeXXS - font.weight: Style.fontWeightMedium - text: { - let firstOfMonth = new Date(grid.year, grid.month, 1); - let firstDayOfWeek = content.firstDayOfWeek; - let firstOfMonthDayOfWeek = firstOfMonth.getDay(); - let daysBeforeFirst = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7; - if (daysBeforeFirst === 0) - daysBeforeFirst = 7; - - let gridStartDate = new Date(grid.year, grid.month, 1 - daysBeforeFirst); - let rowStartDate = new Date(gridStartDate); - rowStartDate.setDate(gridStartDate.getDate() + (index * 7)); - let thursday = new Date(rowStartDate); - if (firstDayOfWeek === 0) { - thursday.setDate(rowStartDate.getDate() + 4); - } else if (firstDayOfWeek === 1) { - thursday.setDate(rowStartDate.getDate() + 3); - } else { - let daysToThursday = (4 - firstDayOfWeek + 7) % 7; - thursday.setDate(rowStartDate.getDate() + daysToThursday); - } - return `${getISOWeekNumber(thursday)}`; - } - } - - } - - } - - } - - // Days Grid - MonthGrid { - id: grid - - Layout.fillWidth: true - Layout.fillHeight: true - spacing: Style.marginXXS - month: Time.date.getMonth() - year: Time.date.getFullYear() - locale: Qt.locale() - - delegate: Item { - Rectangle { - width: Style.baseWidgetSize * 0.9 - height: Style.baseWidgetSize * 0.9 - anchors.centerIn: parent - radius: Style.radiusM - color: model.today ? Color.mSecondary : Color.transparent - - NText { - anchors.centerIn: parent - text: model.day - color: { - if (model.today) - return Color.mOnSecondary; - - if (model.month === grid.month) - return Color.mOnSurface; - - return Color.mOnSurfaceVariant; - } - opacity: model.month === grid.month ? 1 : 0.4 - pointSize: Style.fontSizeM - font.weight: model.today ? Style.fontWeightBold : Style.fontWeightMedium - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsControl.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsControl.qml deleted file mode 100644 index 056d9c9..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsControl.qml +++ /dev/null @@ -1,96 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Modules.Bar.Misc -import qs.Noctalia -import qs.Services - -GridLayout { - id: buttonsGrid - - columns: 2 - columnSpacing: 10 - rowSpacing: 10 - Layout.margins: 10 - - NIconButton { - id: slowerButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.blue - colorFg: Colors.blue - icon: "arrow-bar-up" - onClicked: { - LyricsService.increaseOffset(); - } - } - - NIconButton { - id: playPauseButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.yellow - colorFg: Colors.yellow - icon: "arrow-bar-down" - onClicked: { - LyricsService.decreaseOffset(); - } - } - - NIconButton { - id: nextButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.green - colorFg: Colors.green - icon: "rotate-clockwise" - onClicked: { - LyricsService.resetOffset(); - } - } - - NIconButton { - id: fasterButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.red - colorFg: Colors.red - icon: "trash" - onClicked: { - LyricsService.clearCache(); - } - } - - NIconButton { - id: barLyricsButton - - baseSize: 32 - colorBg: SettingsService.showLyricsBar ? Colors.peach : Color.transparent - colorBgHover: Colors.peach - colorFg: SettingsService.showLyricsBar ? Colors.base : Colors.peach - icon: "app-window" - onClicked: { - SettingsService.showLyricsBar = !SettingsService.showLyricsBar; - } - } - - NIconButton { - id: textButton - - baseSize: 32 - colorBg: Color.transparent - colorBgHover: Colors.subtext1 - colorFg: Colors.subtext1 - icon: "align-box-left-bottom" - onClicked: { - LyricsService.showLyricsText(); - controlCenterPanel.close(); - } - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/MediaCard.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/MediaCard.qml deleted file mode 100644 index 9ef375d..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/MediaCard.qml +++ /dev/null @@ -1,458 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -NBox { - id: root - - // Background artwork that covers everything - Item { - anchors.fill: parent - clip: true - - NImageRounded { - id: bgArtImage - - anchors.fill: parent - imagePath: MusicManager.trackArtUrl - imageRadius: Style.radiusM - visible: MusicManager.trackArtUrl !== "" - } - - // Dark overlay for readability - Rectangle { - anchors.fill: parent - color: Color.mSurfaceVariant - opacity: 0.85 - radius: Style.radiusM - } - - // Border - Rectangle { - anchors.fill: parent - color: Color.transparent - radius: Style.radiusM - } - - } - - // Background visualizer on top of the artwork - Item { - id: visualizerContainer - - anchors.fill: parent - layer.enabled: true - - Item { - anchors.fill: parent - - Cava { - id: cava - - count: 32 - } - - Repeater { - model: cava.values - - Rectangle { - anchors.bottom: parent.bottom - width: (parent.width - (cava.count - 1) * Style.marginXS) / cava.count - height: modelData * parent.height - x: index * (width + Style.marginXS) - color: Color.mPrimary - radius: width / 2 - opacity: 0.25 - } - - } - - } - - layer.effect: MultiEffect { - maskEnabled: true - maskThresholdMin: 0.5 - maskSpreadAtMin: 0 - - maskSource: ShaderEffectSource { - - sourceItem: Rectangle { - width: root.width - height: root.height - radius: Style.radiusM - color: "white" - } - - } - - } - - } - - // Player selector - positioned at the very top - Rectangle { - id: playerSelectorButton - - property var currentPlayer: MusicManager.getAvailablePlayers()[MusicManager.selectedPlayerIndex] - - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.topMargin: Style.marginXS - anchors.leftMargin: Style.marginM - anchors.rightMargin: Style.marginM - height: Style.barHeight - visible: MusicManager.getAvailablePlayers().length > 1 - radius: Style.radiusM - color: Color.transparent - Component.onCompleted: { - MusicManager.selectedPlayerIndex = -1; - } - Component.onDestruction: { - MusicManager.selectedPlayerIndex = -1; - } - - RowLayout { - anchors.fill: parent - spacing: Style.marginS - - NIcon { - icon: "caret-down" - pointSize: Style.fontSizeXXL - color: Color.mOnSurfaceVariant - } - - NText { - text: playerSelectorButton.currentPlayer ? playerSelectorButton.currentPlayer.identity : "" - pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant - Layout.fillWidth: true - } - - } - - MouseArea { - id: playerSelectorMouseArea - - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - var menuItems = []; - var players = MusicManager.getAvailablePlayers(); - for (var i = 0; i < players.length; i++) { - menuItems.push({ - "label": players[i].identity, - "action": i.toString(), - "icon": "disc", - "enabled": true, - "visible": true - }); - } - playerContextMenu.model = menuItems; - playerContextMenu.openAtItem(playerSelectorButton, playerSelectorButton.width - playerContextMenu.width, playerSelectorButton.height); - } - } - - NContextMenu { - id: playerContextMenu - - parent: root - width: 200 - onTriggered: function(action) { - var index = parseInt(action); - if (!isNaN(index)) { - MusicManager.selectedPlayerIndex = index; - MusicManager.updateCurrentPlayer(); - } - } - } - - } - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginM - - // No media player detected - ColumnLayout { - id: fallback - - visible: !main.visible - spacing: Style.marginS - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - - ColumnLayout { - anchors.centerIn: parent - spacing: Style.marginL - - Item { - Layout.alignment: Qt.AlignHCenter - Layout.preferredWidth: Style.fontSizeXXXL * 4 - Layout.preferredHeight: Style.fontSizeXXXL * 4 - - // Pulsating audio circles (background) - Repeater { - model: 3 - - Rectangle { - anchors.centerIn: parent - width: parent.width * (1 + index * 0.2) - height: width - radius: width / 2 - color: "transparent" - border.color: Color.mOnSurfaceVariant - border.width: 2 - opacity: 0 - - SequentialAnimation on opacity { - running: true - loops: Animation.Infinite - - PauseAnimation { - duration: index * 600 - } - - NumberAnimation { - from: 1 - to: 0 - duration: 2000 - easing.type: Easing.OutQuad - } - - } - - SequentialAnimation on scale { - running: true - loops: Animation.Infinite - - PauseAnimation { - duration: index * 600 - } - - NumberAnimation { - from: 0.5 - to: 1.2 - duration: 2000 - easing.type: Easing.OutQuad - } - - } - - } - - } - - // Spinning disc - NIcon { - anchors.centerIn: parent - icon: "disc" - pointSize: Style.fontSizeXXXL * 3 - color: Color.mOnSurfaceVariant - - RotationAnimator on rotation { - from: 0 - to: 360 - duration: 8000 - loops: Animation.Infinite - running: true - } - - } - - } - - // Descriptive text - ColumnLayout { - Layout.alignment: Qt.AlignHCenter - spacing: Style.marginXS - } - - } - - } - - Item { - Layout.fillWidth: true - Layout.fillHeight: true - } - - } - - // MediaPlayer Main Content - ColumnLayout { - id: main - - visible: MusicManager.currentPlayer && MusicManager.canPlay - spacing: Style.marginS - - // Spacer to push content down - Item { - Layout.preferredHeight: Style.marginM - } - - // Metadata at the bottom left - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignLeft - spacing: Style.marginXS - - NText { - visible: MusicManager.trackTitle !== "" - text: MusicManager.trackTitle - pointSize: Style.fontSizeM - font.weight: Style.fontWeightBold - elide: Text.ElideRight - wrapMode: Text.Wrap - Layout.fillWidth: true - maximumLineCount: 1 - } - - NText { - visible: MusicManager.trackArtist !== "" - text: MusicManager.trackArtist - color: Color.mPrimary - pointSize: Style.fontSizeS - elide: Text.ElideRight - Layout.fillWidth: true - maximumLineCount: 1 - } - - NText { - visible: MusicManager.trackAlbum !== "" - text: MusicManager.trackAlbum - color: Color.mOnSurfaceVariant - pointSize: Style.fontSizeM - elide: Text.ElideRight - Layout.fillWidth: true - maximumLineCount: 1 - } - - } - - // Progress slider - Item { - id: progressWrapper - - property real localSeekRatio: -1 - property real lastSentSeekRatio: -1 - property real seekEpsilon: 0.01 - property real progressRatio: { - if (!MusicManager.currentPlayer || MusicManager.trackLength <= 0) - return 0; - - const r = MusicManager.currentPosition / MusicManager.trackLength; - if (isNaN(r) || !isFinite(r)) - return 0; - - return Math.max(0, Math.min(1, r)); - } - property real effectiveRatio: (MusicManager.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio - - visible: (MusicManager.currentPlayer && MusicManager.trackLength > 0) - Layout.fillWidth: true - height: Style.baseWidgetSize * 0.5 - - Timer { - id: seekDebounce - - interval: 75 - repeat: false - onTriggered: { - if (MusicManager.isSeeking && progressWrapper.localSeekRatio >= 0) { - const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio)); - if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) { - MusicManager.seekByRatio(next); - progressWrapper.lastSentSeekRatio = next; - } - } - } - } - - NSlider { - id: progressSlider - - anchors.fill: parent - from: 0 - to: 1 - stepSize: 0 - snapAlways: false - enabled: MusicManager.trackLength > 0 && MusicManager.canSeek - heightRatio: 0.65 - onMoved: { - progressWrapper.localSeekRatio = value; - seekDebounce.restart(); - } - onPressedChanged: { - if (pressed) { - MusicManager.isSeeking = true; - progressWrapper.localSeekRatio = value; - MusicManager.seekByRatio(value); - progressWrapper.lastSentSeekRatio = value; - } else { - seekDebounce.stop(); - MusicManager.seekByRatio(value); - MusicManager.isSeeking = false; - progressWrapper.localSeekRatio = -1; - progressWrapper.lastSentSeekRatio = -1; - } - } - } - - Binding { - target: progressSlider - property: "value" - value: progressWrapper.progressRatio - when: !MusicManager.isSeeking - } - - } - - // Media controls - RowLayout { - spacing: Style.marginS - Layout.fillWidth: true - Layout.alignment: Qt.AlignHCenter - - NIconButton { - icon: "media-prev" - visible: MusicManager.canGoPrevious - onClicked: MusicManager.canGoPrevious ? MusicManager.previous() : { - } - } - - NIconButton { - icon: MusicManager.isPlaying ? "media-pause" : "media-play" - visible: (MusicManager.canPlay || MusicManager.canPause) - onClicked: (MusicManager.canPlay || MusicManager.canPause) ? MusicManager.playPause() : { - } - } - - NIconButton { - icon: "media-next" - visible: MusicManager.canGoNext - onClicked: MusicManager.canGoNext ? MusicManager.next() : { - } - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml deleted file mode 100644 index 0ed7ea2..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/SystemMonitorCard.qml +++ /dev/null @@ -1,95 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Modules.Panel.Misc -import qs.Noctalia -import qs.Services -import qs.Utils - -ColumnLayout { - id: root - - spacing: 0 - - RowLayout { - id: sunsetControlRow - - Layout.fillWidth: true - - NIconButton { - id: barLyricsButton - - implicitHeight: 32 - implicitWidth: 32 - colorBg: SunsetService.isRunning ? Colors.flamingo : Color.transparent - colorBgHover: Colors.flamingo - colorFg: SunsetService.isRunning ? Colors.base : Colors.flamingo - icon: "sunset-2" - onClicked: SunsetService.toggleSunset() - } - - NText { - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - horizontalAlignment: Text.AlignHCenter - text: SunsetService.isRunning ? "Temp: " + SunsetService.temperature + " K" : "Sunset Off" - } - - } - - NBox { - id: monitors - - compact: true - Layout.fillWidth: true - Layout.fillHeight: true - - ColumnLayout { - id: content - - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginS - - MonitorSlider { - icon: "cpu-usage" - value: SystemStatService.cpuUsage - from: 0 - to: 100 - colorFill: Colors.teal - Layout.fillWidth: true - } - - MonitorSlider { - icon: "memory" - value: SystemStatService.memPercent - from: 0 - to: 100 - colorFill: Colors.green - Layout.fillWidth: true - } - - MonitorSlider { - icon: "cpu-temperature" - value: SystemStatService.cpuTemp - from: 0 - to: 100 - colorFill: Colors.yellow - Layout.fillWidth: true - } - - MonitorSlider { - icon: "storage" - value: SystemStatService.diskPercent - from: 0 - to: 100 - colorFill: Colors.peach - Layout.fillWidth: true - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/TopLeftCard.qml b/config/quickshell/.config/quickshell/Modules/Panel/Cards/TopLeftCard.qml deleted file mode 100644 index cbfd7bb..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/TopLeftCard.qml +++ /dev/null @@ -1,175 +0,0 @@ -import QtQuick -import QtQuick.Effects -import QtQuick.Layouts -import Quickshell -import Quickshell.Io -import Quickshell.Services.UPower -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -ColumnLayout { - id: root - - readonly property bool hasPP: PowerProfileService.available - - spacing: Style.marginM - - NBox { - id: whoamiBox - - property string uptimeText: "--" - property string hostname: "--" - - function updateSystemInfo() { - uptimeProcess.running = true; - hostnameProcess.running = true; - } - - Layout.fillWidth: true - Layout.fillHeight: true - - RowLayout { - id: content - - spacing: root.spacing - anchors.fill: parent - anchors.margins: root.spacing - - NImageCircled { - width: Style.baseWidgetSize * 1.5 - height: Style.baseWidgetSize * 1.5 - imagePath: Quickshell.shellDir + "/Assets/Images/Avatar.jpg" - fallbackIcon: "person" - borderColor: Color.mPrimary - borderWidth: Math.max(1, Style.borderM) - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: Style.marginXS - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Style.marginXXS - - NText { - text: `${Quickshell.env("USER") || "user"} @ ${whoamiBox.hostname}` - font.weight: Style.fontWeightBold - font.pointSize: Style.fontSizeL - font.capitalization: Font.Capitalize - } - - NText { - text: "Uptime: " + whoamiBox.uptimeText - font.pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - } - - } - - Item { - Layout.fillWidth: true - } - - } - - // ---------------------------------- - // Uptime - Timer { - interval: 60000 - repeat: true - running: true - onTriggered: uptimeProcess.running = true - } - - Process { - id: uptimeProcess - - command: ["cat", "/proc/uptime"] - running: true - - stdout: StdioCollector { - onStreamFinished: { - var uptimeSeconds = parseFloat(this.text.trim().split(' ')[0]); - whoamiBox.uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds); - uptimeProcess.running = false; - } - } - - } - - Process { - id: hostnameProcess - - command: ["cat", "/etc/hostname"] - running: true - - stdout: StdioCollector { - onStreamFinished: { - whoamiBox.hostname = this.text.trim(); - hostnameProcess.running = false; - } - } - - } - - } - - RowLayout { - id: utilitiesRow - - Layout.fillWidth: true - - // Performance - NIconButton { - implicitHeight: 32 - implicitWidth: 32 - icon: PowerProfileService.getIcon(PowerProfile.Performance) - enabled: hasPP - opacity: enabled ? Style.opacityFull : Style.opacityMedium - colorBgHover: Colors.red - colorBg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Colors.red : Color.transparent - colorFg: (enabled && PowerProfileService.profile === PowerProfile.Performance) ? Color.mOnPrimary : Colors.red - onClicked: PowerProfileService.setProfile(PowerProfile.Performance) - } - - // Balanced - NIconButton { - implicitHeight: 32 - implicitWidth: 32 - icon: PowerProfileService.getIcon(PowerProfile.Balanced) - enabled: hasPP - opacity: enabled ? Style.opacityFull : Style.opacityMedium - colorBgHover: Colors.blue - colorBg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Colors.blue : Color.transparent - colorFg: (enabled && PowerProfileService.profile === PowerProfile.Balanced) ? Color.mOnPrimary : Colors.blue - onClicked: PowerProfileService.setProfile(PowerProfile.Balanced) - } - - // Eco - NIconButton { - implicitHeight: 32 - implicitWidth: 32 - icon: PowerProfileService.getIcon(PowerProfile.PowerSaver) - enabled: hasPP - opacity: enabled ? Style.opacityFull : Style.opacityMedium - colorBgHover: Colors.green - colorBg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Colors.green : Color.transparent - colorFg: (enabled && PowerProfileService.profile === PowerProfile.PowerSaver) ? Color.mOnPrimary : Colors.green - onClicked: PowerProfileService.setProfile(PowerProfile.PowerSaver) - } - - Item { - Layout.fillWidth: true - } - - // Lyrics Offset - NText { - text: `Lyrics Offset: ${LyricsService.offset >= 0 ? '+' : ''}${LyricsService.offset} ms` - Layout.alignment: Qt.AlignVCenter - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/ControlCenterPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/ControlCenterPanel.qml deleted file mode 100644 index af6150a..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/ControlCenterPanel.qml +++ /dev/null @@ -1,87 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Services.Pipewire -import qs.Constants -import qs.Modules.Panel.Cards -import qs.Noctalia -import qs.Services -import qs.Utils - -NPanel { - id: root - - // Positioning - readonly property string controlCenterPosition: "top_left" - property real topCardHeight: 120 - property real middleCardHeight: 100 - property real bottomCardHeight: 200 - - preferredWidth: 480 - preferredHeight: topCardHeight + middleCardHeight + bottomCardHeight + Style.marginL * 4 - panelKeyboardFocus: false - panelAnchorHorizontalCenter: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_center") - panelAnchorVerticalCenter: false - panelAnchorLeft: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_left") - panelAnchorRight: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.endsWith("_right") - panelAnchorBottom: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("bottom_") - panelAnchorTop: controlCenterPosition !== "close_to_bar_button" && controlCenterPosition.startsWith("top_") - - panelContent: Item { - id: content - - property real cardSpacing: Style.marginL - - // Layout content - ColumnLayout { - id: layout - - anchors.fill: parent - anchors.margins: content.cardSpacing - spacing: content.cardSpacing - - // Top Card: profile + utilities - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: topCardHeight - - TopLeftCard { - Layout.fillWidth: true - Layout.maximumHeight: topCardHeight - } - - LyricsControl { - Layout.preferredHeight: topCardHeight - } - - } - - LyricsCard { - Layout.fillWidth: true - Layout.preferredHeight: middleCardHeight - } - - // Media + stats column - RowLayout { - Layout.fillWidth: true - Layout.preferredHeight: bottomCardHeight - spacing: content.cardSpacing - - SystemMonitorCard { - Layout.fillWidth: true - Layout.preferredHeight: bottomCardHeight - } - - MediaCard { - Layout.preferredWidth: 270 - Layout.preferredHeight: bottomCardHeight - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Misc/MonitorSlider.qml b/config/quickshell/.config/quickshell/Modules/Panel/Misc/MonitorSlider.qml deleted file mode 100644 index 778d113..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/Misc/MonitorSlider.qml +++ /dev/null @@ -1,54 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Constants -import qs.Noctalia - -Item { - id: root - - property string icon: "volume-high" - property real value: 50 - property real from: 0 - property real to: 100 - property color colorFill: Colors.primary - property color colorRest: Colors.surface0 - - implicitHeight: layout.implicitHeight - - RowLayout { - id: layout - - anchors.fill: parent - spacing: Style.marginS - - NIcon { - id: iconItem - - icon: root.icon - color: root.colorFill - } - - Rectangle { - id: whole - - Layout.fillWidth: true - color: root.colorRest - radius: height / 2 - height: Style.baseWidgetSize * 0.3 - - Rectangle { - id: fill - - width: Math.max(0, Math.min(whole.width, (root.value - root.from) / (root.to - root.from) * whole.width)) - height: parent.height - color: root.colorFill - radius: height / 2 - anchors.left: parent.left - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/NotificationHistoryPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/NotificationHistoryPanel.qml deleted file mode 100644 index 903aa0b..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/NotificationHistoryPanel.qml +++ /dev/null @@ -1,341 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Services.Notifications -import Quickshell.Wayland -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -// Notification History panel -NPanel { - id: root - - preferredWidth: 380 - preferredHeight: 480 - - panelContent: Rectangle { - id: notificationRect - - color: Color.transparent - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - // Header section - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - NIcon { - icon: "bell" - pointSize: Style.fontSizeXXL - color: Color.mPrimary - } - - NText { - text: "Notifications" - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Color.mOnSurface - Layout.fillWidth: true - } - - NIconButton { - icon: SettingsService.notifications.doNotDisturb ? "bell-off" : "bell" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: SettingsService.notifications.doNotDisturb = !SettingsService.notifications.doNotDisturb - colorFg: SettingsService.notifications.doNotDisturb ? Colors.base : Colors.green - colorBg: SettingsService.notifications.doNotDisturb ? Colors.green : Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.green - } - - NIconButton { - icon: "trash" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - NotificationService.clearHistory(); - // Close panel as there is nothing more to see. - root.close(); - } - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: root.close() - colorFg: Colors.blue - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.blue - } - - } - - NDivider { - Layout.fillWidth: true - } - - // Empty state when no notifications - ColumnLayout { - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignHCenter - visible: NotificationService.historyList.count === 0 - spacing: Style.marginL - - Item { - Layout.fillHeight: true - } - - NIcon { - icon: "bell-off" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "No Notifications" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - Item { - Layout.fillHeight: true - } - - } - - // Notification list - NListView { - id: notificationList - - // Track which notification is expanded - property string expandedId: "" - - Layout.fillWidth: true - Layout.fillHeight: true - horizontalPolicy: ScrollBar.AlwaysOff - verticalPolicy: ScrollBar.AsNeeded - model: NotificationService.historyList - spacing: Style.marginM - clip: true - boundsBehavior: Flickable.StopAtBounds - visible: NotificationService.historyList.count > 0 - - delegate: NBox { - property string notificationId: model.id - property bool isExpanded: notificationList.expandedId === notificationId - - width: notificationList.width - height: notificationLayout.implicitHeight + (Style.marginM * 2) - - // Click to expand/collapse - MouseArea { - anchors.fill: parent - // Don't capture clicks on the delete button - anchors.rightMargin: 48 - enabled: (summaryText.truncated || bodyText.truncated) - onClicked: { - if (notificationList.expandedId === notificationId) - notificationList.expandedId = ""; - else - notificationList.expandedId = notificationId; - } - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - } - - RowLayout { - id: notificationLayout - - anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginM - - ColumnLayout { - NImageCircled { - Layout.preferredWidth: 40 - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignTop - Layout.topMargin: 20 - imagePath: model.cachedImage || model.originalImage || "" - borderColor: Color.transparent - borderWidth: 0 - fallbackIcon: "bell" - fallbackIconSize: 24 - } - - Item { - Layout.fillHeight: true - } - - } - - // Notification content column - ColumnLayout { - Layout.fillWidth: true - Layout.alignment: Qt.AlignTop - spacing: Style.marginXS - Layout.rightMargin: -(Style.marginM + Style.baseWidgetSize * 0.6) - - // Header row with app name and timestamp - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - // Urgency indicator - Rectangle { - Layout.preferredWidth: 6 - Layout.preferredHeight: 6 - Layout.alignment: Qt.AlignVCenter - radius: 3 - visible: model.urgency !== 1 - color: { - if (model.urgency === 2) - return Color.mError; - else if (model.urgency === 0) - return Color.mOnSurfaceVariant; - else - return Color.transparent; - } - } - - NText { - text: model.appName || "Unknown App" - pointSize: Style.fontSizeXS - color: Color.mSecondary - family: Fonts.sans - } - - NText { - text: Time.formatRelativeTime(model.timestamp) - pointSize: Style.fontSizeXS - color: Color.mSecondary - family: Fonts.sans - } - - Item { - Layout.fillWidth: true - } - - } - - // Summary - NText { - id: summaryText - - text: model.summary || "No Summary" - pointSize: Style.fontSizeM - font.weight: Font.Medium - color: Color.mOnSurface - textFormat: Text.PlainText - wrapMode: Text.Wrap - Layout.fillWidth: true - maximumLineCount: isExpanded ? 999 : 2 - family: Fonts.sans - elide: Text.ElideRight - } - - // Body - NText { - id: bodyText - - text: model.body || "" - pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - textFormat: Text.PlainText - wrapMode: Text.Wrap - Layout.fillWidth: true - maximumLineCount: isExpanded ? 999 : 3 - elide: Text.ElideRight - family: Fonts.sans - visible: text.length > 0 - } - - // Spacer for expand indicator - Item { - Layout.fillWidth: true - Layout.preferredHeight: (!isExpanded && (summaryText.truncated || bodyText.truncated)) ? (Style.marginS) : 0 - } - - // Expand indicator - RowLayout { - Layout.fillWidth: true - visible: !isExpanded && (summaryText.truncated || bodyText.truncated) - spacing: Style.marginXS - - Item { - Layout.fillWidth: true - } - - NText { - text: "Click to expand" - pointSize: Style.fontSizeXS - color: Color.mPrimary - family: Fonts.sans - font.weight: Font.Medium - } - - NIcon { - icon: "chevron-down" - pointSize: Style.fontSizeS - color: Color.mPrimary - } - - } - - } - - // Delete button - NIconButton { - icon: "trash" - baseSize: Style.baseWidgetSize * 0.7 - Layout.alignment: Qt.AlignTop - onClicked: { - // Remove from history using the service API - NotificationService.removeFromHistory(notificationId); - } - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - } - - Behavior on height { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.InOutQuad - } - - } - - // Smooth color transition on hover - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/WiFiPanel.qml b/config/quickshell/.config/quickshell/Modules/Panel/WiFiPanel.qml deleted file mode 100644 index ebf7a8e..0000000 --- a/config/quickshell/.config/quickshell/Modules/Panel/WiFiPanel.qml +++ /dev/null @@ -1,633 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Wayland -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -NPanel { - id: root - - property string passwordSsid: "" - property string passwordInput: "" - property string expandedSsid: "" - - preferredWidth: 400 - preferredHeight: 500 - onOpened: NetworkService.scan() - - panelContent: Rectangle { - color: Color.transparent - - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.marginL - spacing: Style.marginM - - // Header - RowLayout { - Layout.fillWidth: true - spacing: Style.marginM - - NIcon { - icon: SettingsService.wifiEnabled ? "wifi" : "wifi-off" - pointSize: Style.fontSizeXXL - color: SettingsService.wifiEnabled ? Color.mPrimary : Color.mOnSurfaceVariant - } - - NText { - text: "WiFi" - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: Color.mOnSurface - Layout.fillWidth: true - } - - NToggle { - id: wifiSwitch - - checked: SettingsService.wifiEnabled - onToggled: (checked) => { - return NetworkService.setWifiEnabled(checked); - } - baseSize: Style.baseWidgetSize * 0.65 - } - - NIconButton { - icon: "refresh" - baseSize: Style.baseWidgetSize * 0.8 - enabled: SettingsService.wifiEnabled && !NetworkService.scanning - onClicked: NetworkService.scan() - colorFg: Colors.green - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.green - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: root.close() - colorFg: Colors.red - colorBg: Color.transparent - colorFgHover: Colors.base - colorBgHover: Colors.red - } - - } - - NDivider { - Layout.fillWidth: true - } - - // Error message - Rectangle { - visible: NetworkService.lastError.length > 0 - Layout.fillWidth: true - Layout.preferredHeight: errorRow.implicitHeight + (Style.marginM * 2) - color: Qt.rgba(Color.mError.r, Color.mError.g, Color.mError.b, 0.1) - radius: Style.radiusS - border.width: Math.max(1, Style.borderS) - border.color: Color.mError - - RowLayout { - id: errorRow - - anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginS - - NIcon { - icon: "warning" - pointSize: Style.fontSizeL - color: Color.mError - } - - NText { - text: NetworkService.lastError - color: Color.mError - pointSize: Style.fontSizeS - wrapMode: Text.Wrap - Layout.fillWidth: true - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.6 - onClicked: NetworkService.lastError = "" - } - - } - - } - - // Main content area - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - color: Color.transparent - - // WiFi disabled state - ColumnLayout { - visible: !SettingsService.wifiEnabled - anchors.fill: parent - spacing: Style.marginM - - Item { - Layout.fillHeight: true - } - - NIcon { - icon: "wifi-off" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Wi-Fi Disabled" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Please enable Wi-Fi to connect to a network." - pointSize: Style.fontSizeS - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - Item { - Layout.fillHeight: true - } - - } - - // Scanning state - ColumnLayout { - visible: SettingsService.wifiEnabled && NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 - anchors.fill: parent - spacing: Style.marginL - - Item { - Layout.fillHeight: true - } - - NBusyIndicator { - running: true - color: Color.mPrimary - size: Style.baseWidgetSize - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "Searching for networks..." - pointSize: Style.fontSizeM - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - Item { - Layout.fillHeight: true - } - - } - - // Networks list container - NScrollView { - visible: SettingsService.wifiEnabled && (!NetworkService.scanning || Object.keys(NetworkService.networks).length > 0) - anchors.fill: parent - horizontalPolicy: ScrollBar.AlwaysOff - verticalPolicy: ScrollBar.AsNeeded - clip: true - - ColumnLayout { - width: parent.width - spacing: Style.marginM - - // Network list - Repeater { - model: { - if (!SettingsService.wifiEnabled) - return []; - - const nets = Object.values(NetworkService.networks); - return nets.sort((a, b) => { - if (a.connected !== b.connected) - return b.connected - a.connected; - - return b.signal - a.signal; - }); - } - - NBox { - Layout.fillWidth: true - implicitHeight: netColumn.implicitHeight + (Style.marginM * 2) - // Add opacity for operations in progress - opacity: (NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid) ? 0.6 : 1 - - ColumnLayout { - id: netColumn - - width: parent.width - (Style.marginM * 2) - x: Style.marginM - y: Style.marginM - spacing: Style.marginS - - // Main row - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS - - NIcon { - icon: NetworkService.signalIcon(modelData.signal) - pointSize: Style.fontSizeXXL - color: modelData.connected ? Color.mPrimary : Color.mOnSurface - } - - ColumnLayout { - Layout.fillWidth: true - spacing: 2 - - NText { - text: modelData.ssid - pointSize: Style.fontSizeM - font.weight: modelData.connected ? Style.fontWeightBold : Style.fontWeightMedium - color: Color.mOnSurface - elide: Text.ElideRight - Layout.fillWidth: true - } - - RowLayout { - spacing: Style.marginXS - - NText { - text: `${modelData.signal}%` - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - NText { - text: "•" - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - NText { - text: NetworkService.isSecured(modelData.security) ? modelData.security : "Open" - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - Item { - Layout.preferredWidth: Style.marginXXS - } - - // Update the status badges area (around line 237) - Rectangle { - visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid - color: Color.mPrimary - radius: height * 0.5 - width: connectedText.implicitWidth + (Style.marginS * 2) - height: connectedText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: connectedText - - anchors.centerIn: parent - text: "Connected" - pointSize: Style.fontSizeXXS - color: Color.mOnPrimary - } - - } - - Rectangle { - visible: NetworkService.disconnectingFrom === modelData.ssid - color: Color.mError - radius: height * 0.5 - width: disconnectingText.implicitWidth + (Style.marginS * 2) - height: disconnectingText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: disconnectingText - - anchors.centerIn: parent - text: "disconnecting" - pointSize: Style.fontSizeXXS - color: Color.mOnPrimary - } - - } - - Rectangle { - visible: NetworkService.forgettingNetwork === modelData.ssid - color: Color.mError - radius: height * 0.5 - width: forgettingText.implicitWidth + (Style.marginS * 2) - height: forgettingText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: forgettingText - - anchors.centerIn: parent - text: "forgetting" - pointSize: Style.fontSizeXXS - color: Color.mOnPrimary - } - - } - - Rectangle { - visible: modelData.cached && !modelData.connected && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid - color: Color.transparent - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) - radius: height * 0.5 - width: savedText.implicitWidth + (Style.marginS * 2) - height: savedText.implicitHeight + (Style.marginXXS * 2) - - NText { - id: savedText - - anchors.centerIn: parent - text: "saved" - pointSize: Style.fontSizeXXS - color: Color.mOnSurfaceVariant - } - - } - - } - - } - - // Action area - RowLayout { - spacing: Style.marginS - - NBusyIndicator { - visible: NetworkService.connectingTo === modelData.ssid || NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid - running: visible - color: Color.mPrimary - size: Style.baseWidgetSize * 0.5 - } - - NIconButton { - visible: (modelData.existing || modelData.cached) && !modelData.connected && NetworkService.connectingTo !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid - icon: "trash" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: expandedSsid = expandedSsid === modelData.ssid ? "" : modelData.ssid - } - - NButton { - visible: !modelData.connected && NetworkService.connectingTo !== modelData.ssid && passwordSsid !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid - text: { - if (modelData.existing || modelData.cached) - return "Connect"; - - if (!NetworkService.isSecured(modelData.security)) - return "Connect"; - - return "Enter Password"; - } - outlined: !hovered - fontSize: Style.fontSizeXS - enabled: !NetworkService.connecting - onClicked: { - if (modelData.existing || modelData.cached || !NetworkService.isSecured(modelData.security)) { - NetworkService.connect(modelData.ssid); - } else { - passwordSsid = modelData.ssid; - passwordInput = ""; - expandedSsid = ""; - } - } - } - - NButton { - visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid - text: "Disconnect" - outlined: !hovered - fontSize: Style.fontSizeXS - backgroundColor: Color.mError - onClicked: NetworkService.disconnect(modelData.ssid) - } - - } - - } - - // Password input - Rectangle { - visible: passwordSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid - Layout.fillWidth: true - height: passwordRow.implicitHeight + Style.marginS * 2 - color: Color.mSurfaceVariant - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS) - radius: Style.radiusS - - RowLayout { - id: passwordRow - - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginM - - Rectangle { - Layout.fillWidth: true - Layout.fillHeight: true - radius: Style.radiusXS - color: Color.mSurface - border.color: pwdInput.activeFocus ? Color.mSecondary : Color.mOutline - border.width: Math.max(1, Style.borderS) - - TextInput { - id: pwdInput - - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.margins: Style.marginS - text: passwordInput - font.family: Fonts.sans - font.pointSize: Style.fontSizeS - color: Color.mOnSurface - echoMode: TextInput.Password - selectByMouse: true - focus: visible - passwordCharacter: "●" - onTextChanged: passwordInput = text - onVisibleChanged: { - if (visible) - forceActiveFocus(); - - } - onAccepted: { - if (text && !NetworkService.connecting) { - NetworkService.connect(passwordSsid, text); - passwordSsid = ""; - passwordInput = ""; - } - } - - NText { - visible: parent.text.length === 0 - anchors.verticalCenter: parent.verticalCenter - text: "Enter Password" - color: Color.mOnSurfaceVariant - pointSize: Style.fontSizeS - } - - } - - } - - NButton { - text: "Connect" - fontSize: Style.fontSizeXXS - enabled: passwordInput.length > 0 && !NetworkService.connecting - outlined: true - onClicked: { - NetworkService.connect(passwordSsid, passwordInput); - passwordSsid = ""; - passwordInput = ""; - } - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - passwordSsid = ""; - passwordInput = ""; - } - } - - } - - } - - // Forget network - Rectangle { - visible: expandedSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid - Layout.fillWidth: true - height: forgetRow.implicitHeight + Style.marginS * 2 - color: Color.mSurfaceVariant - radius: Style.radiusS - border.width: Math.max(1, Style.borderS) - border.color: Color.mOutline - - RowLayout { - id: forgetRow - - anchors.fill: parent - anchors.margins: Style.marginS - spacing: Style.marginM - - RowLayout { - NIcon { - icon: "trash" - pointSize: Style.fontSizeL - color: Color.mError - } - - NText { - text: "Forget this network?" - pointSize: Style.fontSizeS - color: Color.mError - Layout.fillWidth: true - } - - } - - NButton { - id: forgetButton - - text: "Forget" - fontSize: Style.fontSizeXXS - backgroundColor: Color.mError - outlined: forgetButton.hovered ? false : true - onClicked: { - NetworkService.forget(modelData.ssid); - expandedSsid = ""; - } - } - - NIconButton { - icon: "close" - baseSize: Style.baseWidgetSize * 0.8 - onClicked: expandedSsid = "" - } - - } - - } - - } - - // Smooth opacity animation - Behavior on opacity { - NumberAnimation { - duration: Style.animationNormal - } - - } - - } - - } - - } - - } - - // Empty state when no networks - ColumnLayout { - visible: SettingsService.wifiEnabled && !NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 - anchors.fill: parent - spacing: Style.marginL - - Item { - Layout.fillHeight: true - } - - NIcon { - icon: "search" - pointSize: 64 - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NText { - text: "No networks found" - pointSize: Style.fontSizeL - color: Color.mOnSurfaceVariant - Layout.alignment: Qt.AlignHCenter - } - - NButton { - text: "Scan Again" - icon: "refresh" - Layout.alignment: Qt.AlignHCenter - onClicked: NetworkService.scan() - } - - Item { - Layout.fillHeight: true - } - - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Components/SidebarWrap.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Components/SidebarWrap.qml new file mode 100644 index 0000000..bfc932e --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Components/SidebarWrap.qml @@ -0,0 +1,79 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Constants +import qs.Modules.Misc +import qs.Modules.Sidebar +import qs.Services + +PanelWindow { + id: root + + property int barWidth: Style.sidebarWidth + property int realWidth: 0 + property bool isOpen: false + property Component contentComponent: null + property bool isLeft: true + + function open() { + realWidth = barWidth; + isOpen = true; + } + + function close() { + realWidth = 0; + isOpen = false; + } + + Component.onCompleted: { + if (root.isLeft) + BarService.registerLeft(modelData.name, root); + else + BarService.registerRight(modelData.name, root); + } + screen: modelData + WlrLayershell.namespace: root.isLeft ? "quickshell-sidebar-left" : "quickshell-sidebar-right" + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.exclusionMode: ExclusionMode.Ignore + implicitWidth: realWidth > 0 ? barWidth : 0 // jump change for better performance (maybe) + visible: realWidth > 0 + margins.top: Style.barHeight + color: Colors.transparent + + anchors { + left: isLeft + top: true + bottom: true + right: !isLeft + } + + Rectangle { + id: sidebarContent + + width: root.barWidth + height: parent.height + x: isLeft ? root.realWidth - root.barWidth : root.barWidth - root.realWidth + color: Colors.mSurface + + Loader { + anchors.fill: parent + active: root.realWidth > 0 + asynchronous: true + sourceComponent: root.contentComponent + } + + } + + mask: Region { + item: sidebarContent + } + + Behavior on realWidth { + NumberAnimation { + duration: Style.animationSlow + easing.type: Easing.InOutCubic + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Misc/BluetoothDevicesList.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Misc/BluetoothDevicesList.qml similarity index 76% rename from config/quickshell/.config/quickshell/Modules/Panel/Misc/BluetoothDevicesList.qml rename to config/quickshell/.config/quickshell/Modules/Sidebar/Misc/BluetoothDevicesList.qml index af6d165..539d6ef 100644 --- a/config/quickshell/.config/quickshell/Modules/Panel/Misc/BluetoothDevicesList.qml +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Misc/BluetoothDevicesList.qml @@ -5,7 +5,7 @@ import Quickshell import Quickshell.Bluetooth import Quickshell.Wayland import qs.Constants -import qs.Noctalia +import qs.Components import qs.Services ColumnLayout { @@ -17,12 +17,12 @@ ColumnLayout { } Layout.fillWidth: true - spacing: Style.marginM + spacing: Style.marginS - NText { + UText { text: root.label pointSize: Style.fontSizeL - color: Color.mSecondary + color: Colors.mPrimary font.weight: Style.fontWeightMedium Layout.fillWidth: true visible: root.model.length > 0 @@ -35,39 +35,41 @@ ColumnLayout { model: root.model visible: BluetoothService.adapter && BluetoothService.adapter.enabled - NBox { + UBox { id: device readonly property bool canConnect: BluetoothService.canConnect(modelData) readonly property bool canDisconnect: BluetoothService.canDisconnect(modelData) readonly property bool isBusy: BluetoothService.isDeviceBusy(modelData) - function getContentColor(defaultColor = Color.mOnSurface) { + compact: true + + function getContentColor(defaultColor = Colors.mOnSurface) { if (modelData.pairing || modelData.state === BluetoothDeviceState.Connecting) - return Color.mPrimary; + return Colors.mPrimary; if (modelData.blocked) - return Color.mError; + return Colors.mError; return defaultColor; } Layout.fillWidth: true - Layout.preferredHeight: deviceLayout.implicitHeight + (Style.marginM * 2) + Layout.preferredHeight: deviceLayout.implicitHeight + (Style.marginS * 2) RowLayout { id: deviceLayout anchors.fill: parent - anchors.margins: Style.marginM - spacing: Style.marginM + anchors.margins: Style.marginS + spacing: Style.marginS Layout.alignment: Qt.AlignVCenter // One device BT icon - NIcon { - icon: BluetoothService.getDeviceIcon(modelData) - pointSize: Style.fontSizeXXL - color: getContentColor(Color.mOnSurface) + UIcon { + iconName: BluetoothService.getDeviceIcon(modelData) + iconSize: Style.fontSizeXXL + color: getContentColor(Colors.mOnSurface) Layout.alignment: Qt.AlignVCenter } @@ -76,21 +78,21 @@ ColumnLayout { spacing: Style.marginXXS // Device name - NText { + UText { text: modelData.name || modelData.deviceName pointSize: Style.fontSizeM font.weight: Style.fontWeightMedium elide: Text.ElideRight - color: getContentColor(Color.mOnSurface) + color: getContentColor(Colors.mOnSurface) Layout.fillWidth: true } // Status - NText { + UText { text: BluetoothService.getStatusString(modelData) visible: text !== "" pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurfaceVariant) + color: getContentColor(Colors.mOnSurfaceVariant) } // Signal Strength @@ -100,34 +102,34 @@ ColumnLayout { spacing: Style.marginXS // Device signal strength - "Unknown" when not connected - NText { + UText { text: BluetoothService.getSignalStrength(modelData) pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurfaceVariant) + color: getContentColor(Colors.mOnSurfaceVariant) } - NIcon { + UIcon { visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked - icon: BluetoothService.getSignalIcon(modelData) - pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurface) + iconName: BluetoothService.getSignalIcon(modelData) + iconSize: Style.fontSizeXS + color: getContentColor(Colors.mOnSurface) } - NText { + UText { visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked text: (modelData.signalStrength !== undefined && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : "" pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurface) + color: getContentColor(Colors.mOnSurface) } } // Battery - NText { + UText { visible: modelData.batteryAvailable text: BluetoothService.getBattery(modelData) pointSize: Style.fontSizeXS - color: getContentColor(Color.mOnSurfaceVariant) + color: getContentColor(Colors.mOnSurfaceVariant) } } @@ -138,19 +140,18 @@ ColumnLayout { } // Call to action - NButton { + UButton { id: button visible: (modelData.state !== BluetoothDeviceState.Connecting) enabled: (canConnect || canDisconnect) && !isBusy - outlined: !button.hovered fontSize: Style.fontSizeXS fontWeight: Style.fontWeightMedium backgroundColor: { if (device.canDisconnect && !isBusy) - return Color.mError; + return Colors.mError; - return Color.mPrimary; + return Colors.mPrimary; } text: { if (modelData.pairing) diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/BluetoothCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/BluetoothCard.qml new file mode 100644 index 0000000..aee94a8 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/BluetoothCard.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Wayland +import qs.Constants +import qs.Services +import qs.Components +import qs.Modules.Sidebar.Misc + +ColumnLayout { + spacing: Style.marginM + + Rectangle { + visible: !(BluetoothService.adapter && BluetoothService.adapter.enabled) + Layout.fillWidth: true + Layout.fillHeight: true + color: Colors.transparent + + // Center the content within this rectangle + ColumnLayout { + anchors.centerIn: parent + spacing: Style.marginM + + UIcon { + iconName: "bluetooth-off" + iconSize: 64 + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Bluetooth is turned off" + pointSize: Style.fontSizeL + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Enable Bluetooth" + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + } + + } + + UScrollView { + visible: BluetoothService.adapter && BluetoothService.adapter.enabled + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: Style.marginM + + // Connected devices + BluetoothDevicesList { + property var items: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return []; + + var filtered = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.blocked && dev.connected; + }); + return BluetoothService.sortDevices(filtered); + } + + label: "Connected Devices" + model: items + visible: items.length > 0 + Layout.fillWidth: true + } + + // Known devices + BluetoothDevicesList { + property var items: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return []; + + var filtered = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.blocked && !dev.connected && (dev.paired || dev.trusted); + }); + return BluetoothService.sortDevices(filtered); + } + + label: "Known Devices" + model: items + visible: items.length > 0 + Layout.fillWidth: true + } + + // Available devices + BluetoothDevicesList { + property var items: { + if (!BluetoothService.adapter || !Bluetooth.devices) + return []; + + var filtered = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.blocked && !dev.paired && !dev.trusted; + }); + return BluetoothService.sortDevices(filtered); + } + + label: "Available Devices" + model: items + visible: items.length > 0 + Layout.fillWidth: true + } + + // Fallback - No devices, scanning + ColumnLayout { + Layout.alignment: Qt.AlignHCenter + spacing: Style.marginL + + visible: { + if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) + return false; + + var availableCount = Bluetooth.devices.values.filter((dev) => { + return dev && !dev.paired && !dev.pairing && !dev.blocked && (dev.signalStrength === undefined || dev.signalStrength > 0); + }).length; + return (availableCount === 0); + } + + UBusyIndicator { + Layout.alignment: Qt.AlignHCenter + running: true + width: Style.fontSizeL + height: Style.fontSizeL + } + + UText { + text: "Pairing Mode" + pointSize: Style.fontSizeM + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + } + + Item { + Layout.fillHeight: true + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarHeaderCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarHeaderCard.qml new file mode 100644 index 0000000..6f37dce --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarHeaderCard.qml @@ -0,0 +1,133 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +UBox { + id: root + + // Internal state + readonly property var now: Time.now + readonly property bool weatherReady: LocationService.data.weather !== null + // Expose current month/year for potential synchronization with CalendarMonthCard + readonly property int currentMonth: now.getMonth() + readonly property int currentYear: now.getFullYear() + + implicitHeight: (60) + Style.marginM * 2 + color: Colors.mPrimary + + ColumnLayout { + id: capsuleColumn + + anchors.top: parent.top + anchors.left: parent.left + anchors.bottom: parent.bottom + anchors.topMargin: Style.marginM + anchors.bottomMargin: Style.marginM + anchors.rightMargin: clockLoader.width + Style.marginXL * 2 + anchors.leftMargin: Style.marginXL + spacing: 0 + + // Combined layout for date, month year, location and time-zone + RowLayout { + Layout.fillWidth: true + height: 60 + clip: true + spacing: Style.marginS + + // Today day number + UText { + Layout.preferredWidth: implicitWidth + elide: Text.ElideNone + clip: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + text: root.now.getDate() + pointSize: Style.fontSizeXXXL * 1.5 + font.weight: Style.fontWeightBold + color: Colors.mOnPrimary + } + + // Month, year, location + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + Layout.bottomMargin: Style.marginXXS + Layout.topMargin: -Style.marginXXS + spacing: -Style.marginXS + + RowLayout { + spacing: Style.marginS + + UText { + text: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][root.currentMonth] + pointSize: Style.fontSizeXL * 1.1 + font.weight: Style.fontWeightBold + color: Colors.mOnPrimary + Layout.alignment: Qt.AlignBaseline + elide: Text.ElideRight + } + + UText { + text: `${root.currentYear}` + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Qt.alpha(Colors.mOnPrimary, 0.7) + Layout.alignment: Qt.AlignBaseline + } + + } + + RowLayout { + spacing: 0 + + UText { + text: { + if (!root.weatherReady) + return "Loading weather..."; + + const chunks = SettingsService.location.split(","); + return chunks[0]; + } + pointSize: Style.fontSizeM + color: Colors.mOnPrimary + Layout.maximumWidth: 150 + elide: Text.ElideRight + } + + UText { + text: root.weatherReady && ` (${LocationService.data.weather.timezone_abbreviation})` + pointSize: Style.fontSizeXS + color: Qt.alpha(Colors.mOnPrimary, 0.7) + } + + } + + } + + // Spacer + Item { + Layout.fillWidth: true + } + + } + + } + + // Analog/Digital clock + UClock { + id: clockLoader + + anchors.right: parent.right + anchors.rightMargin: Style.marginXL + anchors.verticalCenter: parent.verticalCenter + clockStyle: "analog" + progressColor: Colors.mOnPrimary + Layout.alignment: Qt.AlignVCenter + now: root.now + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarMonthCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarMonthCard.qml new file mode 100644 index 0000000..ad0bd12 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/CalendarMonthCard.qml @@ -0,0 +1,344 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Utils + +// Calendar month grid with navigation +UBox { + id: root + + // Internal state - independent from header + readonly property var now: Time.now + property int calendarMonth: now.getMonth() + property int calendarYear: now.getFullYear() + readonly property var locale: Qt.locale("en") + readonly property int firstDayOfWeek: locale.firstDayOfWeek + + // Helper function to calculate ISO week number + function getISOWeekNumber(date) { + const target = new Date(date.valueOf()); + const dayNr = (date.getDay() + 6) % 7; + target.setDate(target.getDate() - dayNr + 3); + const firstThursday = new Date(target.getFullYear(), 0, 4); + const diff = target - firstThursday; + const oneWeek = 1000 * 60 * 60 * 24 * 7; + const weekNumber = 1 + Math.round(diff / oneWeek); + return weekNumber; + } + + // Helper function to check if an event is all-day + function isAllDayEvent(event) { + const duration = event.end - event.start; + const startDate = new Date(event.start * 1000); + const isAtMidnight = startDate.getHours() === 0 && startDate.getMinutes() === 0; + return duration === 86400 && isAtMidnight; + } + + // Navigation functions + function navigateToPreviousMonth() { + let newDate = new Date(root.calendarYear, root.calendarMonth - 1, 1); + root.calendarYear = newDate.getFullYear(); + root.calendarMonth = newDate.getMonth(); + const now = new Date(); + const monthStart = new Date(root.calendarYear, root.calendarMonth, 1); + const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0); + const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))); + const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))); + } + + function navigateToNextMonth() { + let newDate = new Date(root.calendarYear, root.calendarMonth + 1, 1); + root.calendarYear = newDate.getFullYear(); + root.calendarMonth = newDate.getMonth(); + const now = new Date(); + const monthStart = new Date(root.calendarYear, root.calendarMonth, 1); + const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0); + const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000))); + const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000))); + } + + Layout.fillWidth: true + implicitHeight: calendarContent.implicitHeight + Style.marginM * 2 + compact: true + + // Wheel handler for month navigation + WheelHandler { + id: wheelHandler + + target: root + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: function(event) { + if (event.angleDelta.y > 0) { + // Scroll up - go to previous month + root.navigateToPreviousMonth(); + event.accepted = true; + } else if (event.angleDelta.y < 0) { + // Scroll down - go to next month + root.navigateToNextMonth(); + event.accepted = true; + } + } + } + + ColumnLayout { + id: calendarContent + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + // Navigation row + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Item { + Layout.preferredWidth: Style.marginS + } + + UText { + text: locale.monthName(root.calendarMonth, Locale.LongFormat).toUpperCase() + " " + root.calendarYear + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Colors.mOnSurface + } + + UDivider { + Layout.fillWidth: true + } + + UIconButton { + iconName: "chevron-left" + onClicked: root.navigateToPreviousMonth() + } + + UIconButton { + iconName: "calendar" + onClicked: { + root.calendarMonth = root.now.getMonth(); + root.calendarYear = root.now.getFullYear(); + } + } + + UIconButton { + iconName: "chevron-right" + onClicked: root.navigateToNextMonth() + } + + } + + // Day names header + RowLayout { + Layout.fillWidth: true + spacing: 0 + + Item { + Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0 + } + + GridLayout { + Layout.fillWidth: true + columns: 7 + rows: 1 + columnSpacing: 0 + rowSpacing: 0 + + Repeater { + model: 7 + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.fontSizeS * 2 + + UText { + anchors.centerIn: parent + text: { + let dayIndex = (root.firstDayOfWeek + index) % 7; + const dayName = locale.dayName(dayIndex, Locale.ShortFormat); + return dayName.substring(0, 2).toUpperCase(); + } + color: Colors.mPrimary + pointSize: Style.fontSizeS + font.weight: Style.fontWeightBold + horizontalAlignment: Text.AlignHCenter + } + + } + + } + + } + + } + + // Calendar grid with week numbers + RowLayout { + Layout.fillWidth: true + spacing: 0 + + // Week numbers column + ColumnLayout { + property var weekNumbers: { + if (!grid.daysModel || grid.daysModel.length === 0) + return []; + + const weeks = []; + const numWeeks = Math.ceil(grid.daysModel.length / 7); + for (var i = 0; i < numWeeks; i++) { + const dayIndex = i * 7; + if (dayIndex < grid.daysModel.length) { + const weekDay = grid.daysModel[dayIndex]; + const date = new Date(weekDay.year, weekDay.month, weekDay.day); + let thursday = new Date(date); + if (root.firstDayOfWeek === 0) { + thursday.setDate(date.getDate() + 4); + } else if (root.firstDayOfWeek === 1) { + thursday.setDate(date.getDate() + 3); + } else { + let daysToThursday = (4 - root.firstDayOfWeek + 7) % 7; + thursday.setDate(date.getDate() + daysToThursday); + } + weeks.push(root.getISOWeekNumber(thursday)); + } + } + return weeks; + } + + Layout.preferredWidth: Style.baseWidgetSize * 0.7 + Layout.alignment: Qt.AlignTop + spacing: Style.marginXXS + + Repeater { + model: parent.weekNumbers + + Item { + Layout.preferredWidth: Style.baseWidgetSize * 0.7 + Layout.preferredHeight: Style.baseWidgetSize * 0.9 + + UText { + anchors.centerIn: parent + color: Qt.alpha(Colors.mPrimary, 0.7) + pointSize: Style.fontSizeXXS + text: modelData + } + + } + + } + + } + + // Calendar grid + GridLayout { + id: grid + + property int month: root.calendarMonth + property int year: root.calendarYear + property var daysModel: { + const firstOfMonth = new Date(year, month, 1); + const lastOfMonth = new Date(year, month + 1, 0); + const daysInMonth = lastOfMonth.getDate(); + const firstDayOfWeek = root.firstDayOfWeek; + const firstOfMonthDayOfWeek = firstOfMonth.getDay(); + let daysBefore = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7; + const lastOfMonthDayOfWeek = lastOfMonth.getDay(); + const daysAfter = (firstDayOfWeek - lastOfMonthDayOfWeek - 1 + 7) % 7; + const days = []; + const today = new Date(); + // Previous month days + const prevMonth = new Date(year, month, 0); + const prevMonthDays = prevMonth.getDate(); + for (var i = daysBefore - 1; i >= 0; i--) { + const day = prevMonthDays - i; + days.push({ + "day": day, + "month": month - 1, + "year": month === 0 ? year - 1 : year, + "today": false, + "currentMonth": false + }); + } + // Current month days + for (var day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month, day); + const isToday = date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); + days.push({ + "day": day, + "month": month, + "year": year, + "today": isToday, + "currentMonth": true + }); + } + // Next month days + for (var i = 1; i <= daysAfter; i++) { + days.push({ + "day": i, + "month": month + 1, + "year": month === 11 ? year + 1 : year, + "today": false, + "currentMonth": false + }); + } + return days; + } + + Layout.fillWidth: true + columns: 7 + columnSpacing: Style.marginXXS + rowSpacing: Style.marginXXS + + Repeater { + model: grid.daysModel + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Style.baseWidgetSize * 0.9 + + Rectangle { + width: Style.baseWidgetSize * 0.9 + height: Style.baseWidgetSize * 0.9 + anchors.centerIn: parent + radius: Style.radiusM + color: modelData.today ? Colors.mPrimary : "transparent" + + UText { + anchors.centerIn: parent + text: modelData.day + color: { + if (modelData.today) + return Colors.mOnPrimary; + + if (modelData.currentMonth) + return Colors.mOnSurface; + + return Colors.mOnSurfaceVariant; + } + opacity: modelData.currentMonth ? 1 : 0.4 + pointSize: Style.fontSizeM + font.weight: modelData.today ? Style.fontWeightBold : Style.fontWeightMedium + } + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/ConnectionCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/ConnectionCard.qml new file mode 100644 index 0000000..b899aa9 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/ConnectionCard.qml @@ -0,0 +1,211 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services + +UBox { + id: root + + property string currentPanel: "bluetooth" // "bluetooth", "wifi" + + implicitHeight: contentLoader.implicitHeight + toggleGroup.implicitHeight + Style.marginXS * 2 + Style.marginS * 2 + + ColumnLayout { + spacing: Style.marginXS + anchors.fill: parent + anchors.margins: Style.marginS + + RowLayout { + Layout.fillWidth: true + + Rectangle { + id: toggleGroup + + Layout.preferredWidth: Style.baseWidgetSize * 2.8 + Layout.preferredHeight: Style.baseWidgetSize + radius: Math.min(Style.radiusS, height / 2) + color: Colors.mSurface + // border.color: Colors.mOutline + + Row { + anchors.fill: parent + spacing: Style.marginS / 2 + + Rectangle { + id: btnBluetooth + + width: root.currentPanel === "bluetooth" ? (parent.width - parent.spacing) * 0.65 : (parent.width - parent.spacing) * 0.35 + height: parent.height + radius: Math.min(Style.radiusS, height / 2) + color: root.currentPanel === "bluetooth" ? Colors.mPrimary : "transparent" + + Behavior on width { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + UIcon { + anchors.centerIn: parent + iconName: "bluetooth" + iconSize: Style.fontSizeL + color: root.currentPanel === "bluetooth" ? Colors.mOnPrimary : Colors.mOnSurface + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.currentPanel = "bluetooth" + cursorShape: Qt.PointingHandCursor + } + } + + Rectangle { + id: btnWifi + + width: root.currentPanel === "wifi" ? (parent.width - parent.spacing) * 0.65 : (parent.width - parent.spacing) * 0.35 + height: parent.height + radius: Math.min(Style.radiusS, height / 2) + color: root.currentPanel === "wifi" ? Colors.mPrimary : "transparent" + + Behavior on width { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + Behavior on color { + ColorAnimation { + duration: 200 + } + } + + UIcon { + anchors.centerIn: parent + iconName: "wifi" + iconSize: Style.fontSizeL + color: root.currentPanel === "wifi" ? Colors.mOnPrimary : Colors.mOnSurface + + Behavior on color { + ColorAnimation { + duration: 200 + } + } + } + + MouseArea { + anchors.fill: parent + onClicked: root.currentPanel = "wifi" + cursorShape: Qt.PointingHandCursor + } + } + } + } + + Item { + Layout.fillWidth: true + } + + Loader { + sourceComponent: currentPanel === "bluetooth" ? bluetoothHeaderComponent : wifiHeaderComponent + + Component { + id: bluetoothHeaderComponent + + RowLayout { + UToggle { + id: bluetoothSwitch + + checked: BluetoothService.enabled + onToggled: (checked) => { + return BluetoothService.setBluetoothEnabled(checked); + } + baseSize: Style.baseWidgetSize * 0.65 + } + + UIconButton { + enabled: BluetoothService.enabled + iconName: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "refresh" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + if (BluetoothService.adapter) + BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering; + + } + colorFg: Colors.mGreen + } + } + } + + Component { + id: wifiHeaderComponent + + RowLayout { + UToggle { + id: wifiSwitch + + checked: SettingsService.wifiEnabled + onToggled: (checked) => { + return NetworkService.setWifiEnabled(checked); + } + baseSize: Style.baseWidgetSize * 0.65 + } + + UIconButton { + iconName: "refresh" + baseSize: Style.baseWidgetSize * 0.8 + enabled: SettingsService.wifiEnabled && !NetworkService.scanning + onClicked: NetworkService.scan() + colorFg: Colors.mGreen + } + } + } + } + + } + + UDivider { + Layout.fillWidth: true + } + + Loader { + id: contentLoader + + Layout.fillWidth: true + Layout.fillHeight: true + + sourceComponent: currentPanel === "bluetooth" ? bluetoothComponent : wifiComponent + + Component { + id: bluetoothComponent + + BluetoothCard { + anchors.fill: parent + anchors.margins: Style.marginS + } + } + + Component { + id: wifiComponent + + WifiCard { + anchors.fill: parent + anchors.margins: Style.marginS + } + } + } + } +} diff --git a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml similarity index 88% rename from config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsCard.qml rename to config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml index 262d32f..5e7a11b 100644 --- a/config/quickshell/.config/quickshell/Modules/Panel/Cards/LyricsCard.qml +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsCard.qml @@ -1,12 +1,12 @@ import QtQuick import QtQuick.Layouts import Quickshell +import qs.Components import qs.Constants -import qs.Noctalia import qs.Services import qs.Utils -NBox { +UBox { id: lyricsBox Component.onCompleted: { @@ -25,13 +25,13 @@ NBox { Repeater { model: LyricsService.lyrics - NText { + UText { Layout.fillWidth: true text: modelData - font.pointSize: index === LyricsService.currentIndex ? Style.fontSizeM : Style.fontSizeS + font.pointSize: index === LyricsService.currentIndex ? Style.fontSizeS : Style.fontSizeXS font.weight: index === LyricsService.currentIndex ? Style.fontWeightBold : Style.fontWeightRegular font.family: Fonts.sans - color: index === LyricsService.currentIndex ? Color.mOnSurface : Color.mOnSurfaceVariant + color: index === LyricsService.currentIndex ? Colors.mOnSurface : Colors.mOnSurfaceVariant horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml new file mode 100644 index 0000000..6be971e --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/LyricsControl.qml @@ -0,0 +1,111 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services + +ColumnLayout { + UText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + Layout.maximumWidth: buttonsGrid.width + Layout.bottomMargin: Style.marginS + horizontalAlignment: Text.AlignHCenter + text: (LyricsService.offset > 0 ? "+" + LyricsService.offset : LyricsService.offset) + " ms" + } + + GridLayout { + id: buttonsGrid + + columns: 2 + columnSpacing: Style.marginS + rowSpacing: Style.marginS + + UIconButton { + id: slowerButton + + baseSize: 32 + colorFg: Colors.mCyan + iconName: "arrow-bar-up" + onClicked: { + LyricsService.increaseOffset(); + } + } + + UIconButton { + id: playPauseButton + + baseSize: 32 + colorFg: Colors.mPurple + iconName: "arrow-bar-down" + onClicked: { + LyricsService.decreaseOffset(); + } + } + + UIconButton { + id: nextButton + + baseSize: 32 + colorFg: Colors.mGreen + iconName: "rotate-clockwise" + onClicked: { + LyricsService.resetOffset(); + } + } + + UIconButton { + id: fasterButton + + baseSize: 32 + colorFg: Colors.mRed + iconName: "trash" + onClicked: { + LyricsService.clearCache(); + } + } + + UIconButton { + id: barLyricsButton + + baseSize: 32 + colorFg: Colors.mSky + alwaysHover: LyricsService.showLyricsBar + iconName: "app-window" + onClicked: { + LyricsService.toggleLyricsBar(); + } + } + + UIconButton { + id: textButton + + baseSize: 32 + colorFg: Colors.mYellow + iconName: "align-box-left-bottom" + onClicked: { + LyricsService.showLyricsText(); + controlCenterPanel.close(); + } + } + + UIconButton { + baseSize: 32 + colorFg: Colors.mOrange + alwaysHover: SunsetService.isEnabled + iconName: "sunset-2" + onClicked: SunsetService.toggleSunset() + } + + UIconButton { + baseSize: 32 + colorFg: Colors.mBlue + alwaysHover: MediaService.autoSwitchingPaused + iconName: "lock-square" + onClicked: MediaService.toggleAutoSwitchingPaused() + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/MediaCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/MediaCard.qml new file mode 100644 index 0000000..98c4d8a --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/MediaCard.qml @@ -0,0 +1,483 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +UBox { + id: root + + implicitHeight: 200 + + // Track whether we have an active media player + readonly property bool hasActivePlayer: MediaService.currentPlayer && MediaService.canPlay + + // Wrapper - rounded rect clipper + Item { + anchors.fill: parent + layer.enabled: true + layer.smooth: true + + // Solid color background (always present as base layer) + Rectangle { + anchors.fill: parent + color: Colors.mSurface + } + + // Background image that covers everything + Image { + id: bgImage + + readonly property int dim: 256 + + anchors.fill: parent + visible: source.toString() !== "" + source: MediaService.trackArtUrl + sourceSize: Qt.size(dim, dim) + fillMode: Image.PreserveAspectCrop + layer.enabled: true + layer.smooth: true + + layer.effect: MultiEffect { + blurEnabled: true + blurMax: 8 + blur: 0.33 + } + + } + + // Dark overlay for readability + Rectangle { + anchors.fill: parent + color: Colors.mSurface + opacity: 0.65 + radius: Style.radiusM + } + + // Background visualizer on top of the artwork + Item { + id: visualizerContainer + + anchors.fill: parent + layer.enabled: true + + Item { + anchors.fill: parent + + Cava { + id: cava + + count: 32 + } + + Repeater { + model: cava.values + + Rectangle { + anchors.bottom: parent.bottom + width: (parent.width - (cava.count - 1) * Style.marginXS) / cava.count + height: modelData * parent.height + x: index * (width + Style.marginXS) + color: Colors.mPrimary + radius: width / 2 + opacity: 0.25 + } + + } + + } + + layer.effect: MultiEffect { + maskEnabled: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 0 + + maskSource: ShaderEffectSource { + + sourceItem: Rectangle { + width: root.width + height: root.height + radius: Style.radiusM + color: "white" + } + + } + + } + + } + + layer.effect: MultiEffect { + maskEnabled: true + maskThresholdMin: 0.95 + maskSpreadAtMin: 0.15 + + maskSource: ShaderEffectSource { + + sourceItem: Rectangle { + width: root.width + height: root.height + radius: Style.radiusM + color: "white" + } + + } + + } + + } + + // Player selector + Rectangle { + id: playerSelectorButton + + property var currentPlayer: MediaService.getAvailablePlayers()[MediaService.selectedPlayerIndex] + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: Style.marginXS + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + height: Style.baseWidgetSize + visible: MediaService.getAvailablePlayers().length > 1 + radius: Style.radiusM + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: Style.marginS + + UIcon { + iconName: "caret-down" + iconSize: Style.fontSizeXXL + color: Colors.mOnSurfaceVariant + } + + UText { + text: playerSelectorButton.currentPlayer ? playerSelectorButton.currentPlayer.identity : "" + pointSize: Style.fontSizeXS + color: Colors.mOnSurfaceVariant + Layout.fillWidth: true + } + + } + + MouseArea { + id: playerSelectorMouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + var menuItems = []; + var players = MediaService.getAvailablePlayers(); + for (var i = 0; i < players.length; i++) { + menuItems.push({ + "label": players[i].identity, + "action": i.toString(), + "icon": "disc", + "enabled": true, + "visible": true + }); + } + playerContextMenu.model = menuItems; + playerContextMenu.openAtItem(playerSelectorButton, 0, playerSelectorButton.height); + } + } + + UContextMenu { + id: playerContextMenu + + parent: root + width: 200 + verticalPolicy: ScrollBar.AlwaysOff + onTriggered: function(action) { + var index = parseInt(action); + if (!isNaN(index)) + MediaService.switchToPlayer(index); + + } + } + + } + + // Content container that adjusts for player selector + Item { + anchors.fill: parent + anchors.topMargin: playerSelectorButton.visible ? (playerSelectorButton.height + Style.marginXS + Style.marginM) : Style.marginM + anchors.leftMargin: Style.marginM + anchors.rightMargin: Style.marginM + anchors.bottomMargin: Style.marginM + + Item { + id: fallback + visible: !root.hasActivePlayer + anchors.fill: parent + + Item { + anchors.centerIn: parent + implicitWidth: Style.fontSizeXXXL * 4 + implicitHeight: Style.fontSizeXXXL * 4 + + // Pulsating audio circles (background) + Repeater { + model: 3 + + Rectangle { + anchors.centerIn: parent + width: parent.width * (1 + index * 0.2) + height: width + radius: width / 2 + color: "transparent" + border.color: Colors.mOnSurfaceVariant + border.width: 2 + opacity: 0 + + SequentialAnimation on opacity { + running: true + loops: Animation.Infinite + + PauseAnimation { + duration: index * 600 + } + + NumberAnimation { + from: 1 + to: 0 + duration: 2000 + easing.type: Easing.OutQuad + } + + } + + SequentialAnimation on scale { + running: true + loops: Animation.Infinite + + PauseAnimation { + duration: index * 600 + } + + NumberAnimation { + from: 0.5 + to: 1.2 + duration: 2000 + easing.type: Easing.OutQuad + } + + } + + } + + } + + // Spinning disc + UIcon { + anchors.centerIn: parent + iconName: "disc" + iconSize: Style.fontSizeXXXL * 3 + color: Colors.mOnSurfaceVariant + + RotationAnimator on rotation { + from: 0 + to: 360 + duration: 8000 + loops: Animation.Infinite + running: true + } + + } + + } + + } + + // MediaPlayer Main Content - use Loader for performance + Loader { + id: mainLoader + + anchors.fill: parent + active: root.hasActivePlayer + + sourceComponent: Item { + Layout.fillWidth: true + Layout.fillHeight: true + + // Exceptionaly we put shadow on text and controls to ease readability + UDropShadow { + anchors.fill: main + source: main + autoPaddingEnabled: true + shadowBlur: 1 + shadowOpacity: 0.9 + shadowHorizontalOffset: 0 + shadowVerticalOffset: 0 + shadowColor: "black" + } + + ColumnLayout { + id: main + + anchors.fill: parent + spacing: Style.marginS + + // Metadata + ColumnLayout { + id: metadata + + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + spacing: Style.marginXS + + UText { + visible: MediaService.trackTitle !== "" + text: MediaService.trackTitle + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + Layout.fillWidth: true + } + + UText { + visible: MediaService.trackArtist !== "" + text: MediaService.trackArtist + color: Colors.mPrimary + pointSize: Style.fontSizeXS + elide: Text.ElideRight + Layout.fillWidth: true + } + + UText { + visible: MediaService.trackAlbum !== "" + text: MediaService.trackAlbum + color: Colors.mOnSurfaceVariant + pointSize: Style.fontSizeS + elide: Text.ElideRight + Layout.fillWidth: true + } + + } + + // Progress slider + Item { + id: progressWrapper + + property real localSeekRatio: -1 + property real lastSentSeekRatio: -1 + property real seekEpsilon: 0.01 + property real progressRatio: { + if (!MediaService.currentPlayer || MediaService.trackLength <= 0) + return 0; + + const r = MediaService.currentPosition / MediaService.trackLength; + if (isNaN(r) || !isFinite(r)) + return 0; + + return Math.max(0, Math.min(1, r)); + } + property real effectiveRatio: (MediaService.isSeeking && localSeekRatio >= 0) ? Math.max(0, Math.min(1, localSeekRatio)) : progressRatio + + visible: (MediaService.currentPlayer && MediaService.trackLength > 0) + Layout.fillWidth: true + height: Style.baseWidgetSize * 0.5 + + Timer { + id: seekDebounce + + interval: 75 + repeat: false + onTriggered: { + if (MediaService.isSeeking && progressWrapper.localSeekRatio >= 0) { + const next = Math.max(0, Math.min(1, progressWrapper.localSeekRatio)); + if (progressWrapper.lastSentSeekRatio < 0 || Math.abs(next - progressWrapper.lastSentSeekRatio) >= progressWrapper.seekEpsilon) { + MediaService.seekByRatio(next); + progressWrapper.lastSentSeekRatio = next; + } + } + } + } + + USlider { + id: progressSlider + + anchors.fill: parent + from: 0 + to: 1 + stepSize: 0 + snapAlways: false + enabled: MediaService.trackLength > 0 && MediaService.canSeek + heightRatio: 0.6 + onMoved: { + progressWrapper.localSeekRatio = value; + seekDebounce.restart(); + } + onPressedChanged: { + if (pressed) { + MediaService.isSeeking = true; + progressWrapper.localSeekRatio = value; + MediaService.seekByRatio(value); + progressWrapper.lastSentSeekRatio = value; + } else { + seekDebounce.stop(); + MediaService.seekByRatio(value); + MediaService.isSeeking = false; + progressWrapper.localSeekRatio = -1; + progressWrapper.lastSentSeekRatio = -1; + } + } + } + + Binding { + target: progressSlider + property: "value" + value: progressWrapper.progressRatio + when: !MediaService.isSeeking + } + + } + + // Media controls + RowLayout { + spacing: Style.marginS + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + + UIconButton { + iconName: "media-prev" + visible: MediaService.canGoPrevious + onClicked: MediaService.canGoPrevious ? MediaService.previous() : { + } + } + + UIconButton { + iconName: MediaService.isPlaying ? "media-pause" : "media-play" + visible: (MediaService.canPlay || MediaService.canPause) + onClicked: (MediaService.canPlay || MediaService.canPause) ? MediaService.playPause() : { + } + } + + UIconButton { + iconName: "media-next" + visible: MediaService.canGoNext + onClicked: MediaService.canGoNext ? MediaService.next() : { + } + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/NotificationHistoryCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/NotificationHistoryCard.qml new file mode 100644 index 0000000..0a55f2d --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/NotificationHistoryCard.qml @@ -0,0 +1,939 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Wayland +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +// Notification History panel +Rectangle { + // Check if below visible area + // Stop at edges + + id: root + + // Calculate content height based on header + tabs (if visible) + content + property real calculatedHeight: { + if (NotificationService.historyList.count === 0) + return headerBox.implicitHeight + scrollView.implicitHeight + Style.marginL * 2 + Style.marginM; + + return headerBox.implicitHeight + scrollView.implicitHeight + Style.marginL * 2 + Style.marginM; + } + property real contentPreferredHeight: Math.min(root.height, Math.ceil(calculatedHeight)) + property real layoutWidth: Math.max(1, root.width - Style.marginL * 2) + // State (lazy-loaded with root) + property var rangeCounts: [0, 0, 0, 0] + property var lastKnownDate: null // Track the current date to detect day changes + // UI state (lazy-loaded with root) + // 0 = All, 1 = Today, 2 = Yesterday, 3 = Earlier + property int currentRange: 1 + // start on Today by default + property bool groupByDate: true + // Keyboard navigation state + property int focusIndex: -1 + property int actionIndex: -1 // For actions within a notification + + function parseActions(actions) { + try { + return JSON.parse(actions || "[]"); + } catch (e) { + return []; + } + } + + function moveSelection(dir) { + var m = NotificationService.historyList; + if (!m || m.count === 0) + return ; + + var newIndex = focusIndex; + var found = false; + var count = m.count; + // If no selection yet, start from beginning (or end if up) + if (focusIndex === -1) { + if (dir > 0) + newIndex = -1; + else + newIndex = count; + } + // Loop to find next visible item + var loopCount = 0; + while (loopCount < count) { + newIndex += dir; + // Bounds check + if (newIndex < 0 || newIndex >= count) + break; + + var item = m.get(newIndex); + if (item && isInCurrentRange(item.timestamp)) { + found = true; + break; + } + loopCount++; + } + if (found) { + focusIndex = newIndex; + actionIndex = -1; // Reset action selection + scrollToItem(focusIndex); + } + } + + function moveAction(dir) { + if (focusIndex === -1) + return ; + + var item = NotificationService.historyList.get(focusIndex); + if (!item) + return ; + + var actions = parseActions(item.actionsJson); + if (actions.length === 0) + return ; + + var newActionIndex = actionIndex + dir; + // Clamp between -1 (body) and actions.length - 1 + if (newActionIndex < -1) + newActionIndex = -1; + + if (newActionIndex >= actions.length) + newActionIndex = actions.length - 1; + + actionIndex = newActionIndex; + } + + function activateSelection() { + if (focusIndex === -1) + return ; + + var item = NotificationService.historyList.get(focusIndex); + if (!item) + return ; + + if (actionIndex >= 0) { + var actions = parseActions(item.actionsJson); + if (actionIndex < actions.length) + NotificationService.invokeAction(item.id, actions[actionIndex].identifier); + + } else { + var delegate = notificationColumn.children[focusIndex]; + if (!delegate) + return ; + + if (!(delegate.canExpand || delegate.isExpanded)) + return ; + + if (scrollView.expandedId === item.id) + scrollView.expandedId = ""; + else + scrollView.expandedId = item.id; + } + } + + function removeSelection() { + if (focusIndex === -1) + return ; + + var item = NotificationService.historyList.get(focusIndex); + if (!item) + return ; + + NotificationService.removeFromHistory(item.id); + } + + function scrollToItem(index) { + // Find the delegate item + if (index < 0 || index >= notificationColumn.children.length) + return ; + + var item = notificationColumn.children[index]; + if (item && item.visible) { + // Use the internal flickable from NScrollView for accurate scrolling + var flickable = scrollView._internalFlickable; + if (!flickable || !flickable.contentItem) + return ; + + var pos = flickable.contentItem.mapFromItem(item, 0, 0); + var itemY = pos.y; + var itemHeight = item.height; + var currentContentY = flickable.contentY; + var viewHeight = flickable.height; + // Check if above visible area + if (itemY < currentContentY) + flickable.contentY = Math.max(0, itemY - Style.marginM); + else if (itemY + itemHeight > currentContentY + viewHeight) + flickable.contentY = (itemY + itemHeight) - viewHeight + Style.marginM; + } + } + + function resetFocus() { + focusIndex = -1; + actionIndex = -1; + } + + // Helper functions (lazy-loaded with root) + function dateOnly(d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + + function getDateKey(d) { + // Returns a string key for the date (YYYY-MM-DD) for comparison + var date = dateOnly(d); + return date.getFullYear() + "-" + date.getMonth() + "-" + date.getDate(); + } + + function rangeForTimestamp(ts) { + var dt = new Date(ts); + var today = dateOnly(new Date()); + var thatDay = dateOnly(dt); + var diffMs = today - thatDay; + var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) + return 0; + + if (diffDays === 1) + return 1; + + return 2; + } + + function recalcRangeCounts() { + var m = NotificationService.historyList; + if (!m || typeof m.count === "undefined" || m.count <= 0) { + root.rangeCounts = [0, 0, 0, 0]; + return ; + } + var counts = [0, 0, 0, 0]; + counts[0] = m.count; + for (var i = 0; i < m.count; ++i) { + var item = m.get(i); + if (!item || typeof item.timestamp === "undefined") + continue; + + var r = rangeForTimestamp(item.timestamp); + counts[r + 1] = counts[r + 1] + 1; + } + root.rangeCounts = counts; + } + + function isInCurrentRange(ts) { + if (currentRange === 0) + return true; + + return rangeForTimestamp(ts) === (currentRange - 1); + } + + function countForRange(range) { + return rangeCounts[range] || 0; + } + + function hasNotificationsInCurrentRange() { + var m = NotificationService.historyList; + if (!m || m.count === 0) + return false; + + for (var i = 0; i < m.count; ++i) { + var item = m.get(i); + if (item && isInCurrentRange(item.timestamp)) + return true; + + } + return false; + } + + color: "transparent" + onCurrentRangeChanged: resetFocus() + Component.onCompleted: { + NotificationService.updateLastSeenTs(); + recalcRangeCounts(); + // Initialize lastKnownDate + lastKnownDate = getDateKey(new Date()); + } + + Connections { + function onCountChanged() { + root.recalcRangeCounts(); + } + + target: NotificationService.historyList + } + + // Timer to check for day changes at midnight + Timer { + // Day has changed, recalculate counts + + id: dayChangeTimer + + interval: 60000 // Check every minute + repeat: true + running: true // Always runs when root exists (panel is open) + onTriggered: { + var currentDateKey = root.getDateKey(new Date()); + if (root.lastKnownDate !== null && root.lastKnownDate !== currentDateKey) + root.recalcRangeCounts(); + + root.lastKnownDate = currentDateKey; + } + } + + ColumnLayout { + id: mainColumn + + anchors.fill: parent + spacing: Style.marginM + + // Header section + UBox { + id: headerBox + + Layout.fillWidth: true + implicitHeight: header.implicitHeight + Style.marginM * 2 + + ColumnLayout { + id: header + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + id: headerRow + + Layout.fillWidth: true + + UIcon { + iconName: "bell" + iconSize: Style.fontSizeXXL + color: Colors.mPrimary + } + + UText { + text: "Notifications" + " (" + root.countForRange(tabsBox.currentIndex) + ")" + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + color: Colors.mOnSurface + Layout.fillWidth: true + } + + UIconButton { + iconName: NotificationService.doNotDisturb ? "bell-off" : "bell" + baseSize: Style.baseWidgetSize * 0.8 + colorFg: Colors.mGreen + alwaysHover: NotificationService.doNotDisturb + onClicked: NotificationService.toggleDoNotDisturb() + } + + UIconButton { + // Close panel as there is nothing more to see. + + iconName: "trash" + baseSize: Style.baseWidgetSize * 0.8 + colorFg: Colors.mError + onClicked: { + NotificationService.clearHistory(); + } + } + + } + + // Time range tabs ([All] / [Today] / [Yesterday] / [Earlier]) + UTabBar { + id: tabsBox + + Layout.fillWidth: true + visible: NotificationService.historyList.count > 0 && root.groupByDate + currentIndex: root.currentRange + tabHeight: Math.round(Style.baseWidgetSize * 0.8) + spacing: Style.marginXS + distributeEvenly: true + + UTabButton { + tabIndex: 0 + text: "All" + checked: tabsBox.currentIndex === 0 + onClicked: root.currentRange = 0 + pointSize: Style.fontSizeXS + } + + UTabButton { + tabIndex: 1 + text: "Today" + checked: tabsBox.currentIndex === 1 + onClicked: root.currentRange = 1 + pointSize: Style.fontSizeXS + } + + UTabButton { + tabIndex: 2 + text: "Yesterday" + checked: tabsBox.currentIndex === 2 + onClicked: root.currentRange = 2 + pointSize: Style.fontSizeXS + } + + UTabButton { + tabIndex: 3 + text: "Earlier" + checked: tabsBox.currentIndex === 3 + onClicked: root.currentRange = 3 + pointSize: Style.fontSizeXS + } + + } + + } + + } + + // Notification list container with gradient overlay + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + NScrollView { + id: scrollView + + // Track which notification is expanded + property string expandedId: "" + + anchors.fill: parent + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + reserveScrollbarSpace: false + gradientColor: Colors.mSurface + + ColumnLayout { + anchors.fill: parent + spacing: Style.marginM + + // Empty state when no notifications + UBox { + visible: !root.hasNotificationsInCurrentRange() + Layout.fillWidth: true + Layout.preferredHeight: emptyState.implicitHeight + Style.marginM * 2 + + ColumnLayout { + id: emptyState + + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + Item { + Layout.fillHeight: true + } + + UIcon { + iconName: "bell-off" + iconSize: (NotificationService.historyList.count === 0) ? 48 : Style.baseWidgetSize + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "No notifications" + pointSize: (NotificationService.historyList.count === 0) ? Style.fontSizeL : Style.fontSizeM + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + visible: NotificationService.historyList.count === 0 + text: "No notifications in this range" + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + Item { + Layout.fillHeight: true + } + + } + + } + + // Notification list container + Item { + visible: root.hasNotificationsInCurrentRange() + Layout.fillWidth: true + Layout.preferredHeight: notificationColumn.implicitHeight + + Column { + id: notificationColumn + + anchors.fill: parent + spacing: Style.marginS + + Repeater { + model: NotificationService.historyList + + delegate: Item { + id: notificationDelegate + + property int listIndex: index + property string notificationId: model.id + property string appName: model.appName || "" + property bool isExpanded: scrollView.expandedId === notificationId + property bool canExpand: summaryText.truncated || bodyText.truncated + property real swipeOffset: 0 + property real pressGlobalX: 0 + property real pressGlobalY: 0 + property bool isSwiping: false + property bool isRemoving: false + property string pendingLink: "" + readonly property real swipeStartThreshold: 16 + readonly property real swipeDismissThreshold: Math.max(110, width * 0.3) + readonly property int removeAnimationDuration: Style.animationNormal + readonly property int notificationTextFormat: notificationDelegate.isExpanded ? Text.MarkdownText : Text.StyledText + readonly property real actionButtonSize: Style.baseWidgetSize * 0.7 + readonly property real buttonClusterWidth: notificationDelegate.actionButtonSize * 2 + Style.marginXS + readonly property real iconSize: 40 + // Parse actions safely + property var actionsList: parseActions(model.actionsJson) + property bool isFocused: index === root.focusIndex + + function isSafeLink(link) { + if (!link) + return false; + + const lower = link.toLowerCase(); + const schemes = ["http://", "https://", "mailto:"]; + return schemes.some((scheme) => { + return lower.startsWith(scheme); + }); + } + + function linkAtPoint(x, y) { + if (!notificationDelegate.isExpanded) + return ""; + + if (summaryText) { + const summaryPoint = summaryText.mapFromItem(historyInteractionArea, x, y); + if (summaryPoint.x >= 0 && summaryPoint.y >= 0 && summaryPoint.x <= summaryText.width && summaryPoint.y <= summaryText.height) { + const summaryLink = summaryText.linkAt ? summaryText.linkAt(summaryPoint.x, summaryPoint.y) : ""; + if (isSafeLink(summaryLink)) + return summaryLink; + + } + } + if (bodyText) { + const bodyPoint = bodyText.mapFromItem(historyInteractionArea, x, y); + if (bodyPoint.x >= 0 && bodyPoint.y >= 0 && bodyPoint.x <= bodyText.width && bodyPoint.y <= bodyText.height) { + const bodyLink = bodyText.linkAt ? bodyText.linkAt(bodyPoint.x, bodyPoint.y) : ""; + if (isSafeLink(bodyLink)) + return bodyLink; + + } + } + return ""; + } + + function updateCursorAt(x, y) { + if (notificationDelegate.isExpanded && notificationDelegate.linkAtPoint(x, y)) + historyInteractionArea.cursorShape = Qt.PointingHandCursor; + else + historyInteractionArea.cursorShape = Qt.ArrowCursor; + } + + function dismissBySwipe() { + if (isRemoving) + return ; + + isRemoving = true; + isSwiping = false; + swipeOffset = swipeOffset >= 0 ? width + Style.marginL : -width - Style.marginL; + opacity = 0; + removeTimer.restart(); + } + + width: parent.width + visible: root.isInCurrentRange(model.timestamp) + height: visible && !isRemoving ? contentColumn.height + Style.marginM * 2 : 0 + onVisibleChanged: { + if (!visible) { + notificationDelegate.isSwiping = false; + notificationDelegate.swipeOffset = 0; + notificationDelegate.opacity = 1; + notificationDelegate.isRemoving = false; + removeTimer.stop(); + } + } + Component.onDestruction: removeTimer.stop() + + Timer { + id: removeTimer + + interval: notificationDelegate.removeAnimationDuration + repeat: false + onTriggered: NotificationService.removeFromHistory(notificationId) + } + + Rectangle { + // return "transparent"; + + anchors.fill: parent + radius: Style.radiusM + color: Colors.mSurfaceVariant + border.color: { + if (notificationDelegate.isFocused) + return Colors.mPrimary; + + return Qt.alpha(Colors.mOutline, Style.opacityHeavy); + } + border.width: notificationDelegate.isFocused ? Style.borderM : Style.borderS + + Behavior on color { + enabled: true + + ColorAnimation { + duration: Style.animationFast + } + + } + + } + + // Click to expand/collapse + MouseArea { + id: historyInteractionArea + + anchors.fill: parent + anchors.rightMargin: notificationDelegate.buttonClusterWidth + Style.marginM + enabled: !notificationDelegate.isRemoving + hoverEnabled: true + cursorShape: Qt.ArrowCursor + onPressed: (mouse) => { + root.focusIndex = index; + root.actionIndex = -1; + if (notificationDelegate.isExpanded) { + const link = notificationDelegate.linkAtPoint(mouse.x, mouse.y); + if (link) + notificationDelegate.pendingLink = link; + else + notificationDelegate.pendingLink = ""; + } + if (mouse.button !== Qt.LeftButton) + return ; + + const globalPoint = historyInteractionArea.mapToGlobal(mouse.x, mouse.y); + notificationDelegate.pressGlobalX = globalPoint.x; + notificationDelegate.pressGlobalY = globalPoint.y; + notificationDelegate.isSwiping = false; + } + onPositionChanged: (mouse) => { + if (!(mouse.buttons & Qt.LeftButton) || notificationDelegate.isRemoving) + return ; + + const globalPoint = historyInteractionArea.mapToGlobal(mouse.x, mouse.y); + const deltaX = globalPoint.x - notificationDelegate.pressGlobalX; + const deltaY = globalPoint.y - notificationDelegate.pressGlobalY; + if (!notificationDelegate.isSwiping) { + if (Math.abs(deltaX) < notificationDelegate.swipeStartThreshold) + return ; + + // Only start a swipe-dismiss when horizontal movement is dominant. + if (Math.abs(deltaX) <= Math.abs(deltaY) * 1.15) + return ; + + notificationDelegate.isSwiping = true; + } + if (notificationDelegate.pendingLink && Math.abs(deltaX) >= notificationDelegate.swipeStartThreshold) + notificationDelegate.pendingLink = ""; + + notificationDelegate.swipeOffset = deltaX; + } + onReleased: (mouse) => { + if (mouse.button !== Qt.LeftButton) + return ; + + if (notificationDelegate.isSwiping) { + if (Math.abs(notificationDelegate.swipeOffset) >= notificationDelegate.swipeDismissThreshold) + notificationDelegate.dismissBySwipe(); + else + notificationDelegate.swipeOffset = 0; + notificationDelegate.isSwiping = false; + notificationDelegate.pendingLink = ""; + return ; + } + if (notificationDelegate.pendingLink) { + Qt.openUrlExternally(notificationDelegate.pendingLink); + notificationDelegate.pendingLink = ""; + return ; + } + // Focus sender window (and invoke default action if available) + var actions = notificationDelegate.actionsList; + var hasDefault = actions.some(function(a) { + return a.identifier === "default"; + }); + if (hasDefault) { + NotificationService.focusSenderWindow(notificationDelegate.appName); + NotificationService.invokeAction(notificationDelegate.notificationId, "default"); + } else { + NotificationService.focusSenderWindow(notificationDelegate.appName); + } + } + onCanceled: { + notificationDelegate.isSwiping = false; + notificationDelegate.swipeOffset = 0; + notificationDelegate.pendingLink = ""; + historyInteractionArea.cursorShape = Qt.ArrowCursor; + } + } + + HoverHandler { + target: historyInteractionArea + onPointChanged: notificationDelegate.updateCursorAt(point.position.x, point.position.y) + onActiveChanged: { + if (!active) + historyInteractionArea.cursorShape = Qt.ArrowCursor; + + } + } + + Column { + id: contentColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.marginM + spacing: Style.marginM + + Row { + width: parent.width + spacing: Style.marginM + + // Icon + UImageRounded { + anchors.verticalCenter: notificationDelegate.isExpanded ? undefined : parent.verticalCenter + width: notificationDelegate.iconSize + height: notificationDelegate.iconSize + radius: Math.min(Style.radiusL, width / 2) + imagePath: model.cachedImage || model.originalImage || "" + borderColor: "transparent" + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 + } + + // Content + Column { + width: parent.width - notificationDelegate.iconSize - notificationDelegate.buttonClusterWidth - Style.marginM * 2 + spacing: Style.marginXS + + // Header row with app name and timestamp + Row { + width: parent.width + spacing: Style.marginS + + // Urgency indicator + Rectangle { + width: 6 + height: 6 + anchors.verticalCenter: parent.verticalCenter + radius: 3 + visible: model.urgency !== 1 + color: { + if (model.urgency === 2) + return Colors.mError; + else if (model.urgency === 0) + return Colors.mOnSurfaceVariant; + else + return "transparent"; + } + } + + UText { + text: model.appName || "Unknown App" + pointSize: Style.fontSizeXS + font.weight: Style.fontWeightBold + color: Colors.mPrimary + } + + UText { + textFormat: Text.PlainText + text: " " + Time.formatRelativeTime(model.timestamp) + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + anchors.bottom: parent.bottom + } + + } + + // Summary + UText { + id: summaryText + + width: parent.width + text: notificationDelegate.isExpanded ? (model.summaryMarkdown || I18n.tr("common.no-summary")) : (model.summary || I18n.tr("common.no-summary")) + pointSize: Style.fontSizeM + color: Colors.mOnSurface + textFormat: notificationDelegate.notificationTextFormat + wrapMode: Text.Wrap + maximumLineCount: notificationDelegate.isExpanded ? 999 : 2 + elide: Text.ElideRight + } + + // Body + UText { + id: bodyText + + width: parent.width + text: notificationDelegate.isExpanded ? (model.bodyMarkdown || "") : (model.body || "") + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + textFormat: notificationDelegate.notificationTextFormat + wrapMode: Text.Wrap + maximumLineCount: notificationDelegate.isExpanded ? 999 : 3 + elide: Text.ElideRight + visible: text.length > 0 + } + + // Actions Flow + Flow { + width: parent.width + spacing: Style.marginS + visible: notificationDelegate.actionsList.length > 0 + + Repeater { + model: notificationDelegate.actionsList + + delegate: UButton { + readonly property bool actionNavActive: notificationDelegate.isFocused && root.actionIndex !== -1 + readonly property bool isSelected: actionNavActive && root.actionIndex === index + // Capture modelData in a property to avoid reference errors + property var actionData: modelData + + text: modelData.text + fontSize: Style.fontSizeS + backgroundColor: isSelected ? Colors.mSecondary : Colors.mPrimary + textColor: isSelected ? Colors.mOnSecondary : Colors.mOnPrimary + outlined: false + implicitHeight: 24 + onHoveredChanged: { + if (hovered) + root.focusIndex = notificationDelegate.listIndex; + + } + onClicked: { + NotificationService.focusSenderWindow(notificationDelegate.appName); + NotificationService.invokeAction(notificationDelegate.notificationId, actionData.identifier); + } + } + + } + + } + + } + + Item { + width: notificationDelegate.buttonClusterWidth + height: notificationDelegate.actionButtonSize + + Row { + anchors.right: parent.right + spacing: Style.marginXS + + UIconButton { + id: expandButton + + iconName: notificationDelegate.isExpanded ? "chevron-up" : "chevron-down" + baseSize: notificationDelegate.actionButtonSize + opacity: (notificationDelegate.canExpand || notificationDelegate.isExpanded) ? 1 : 0 + enabled: notificationDelegate.canExpand || notificationDelegate.isExpanded + onClicked: { + notificationDelegate.pendingLink = ""; + historyInteractionArea.cursorShape = Qt.ArrowCursor; + if (scrollView.expandedId === notificationId) + scrollView.expandedId = ""; + else + scrollView.expandedId = notificationId; + } + } + + // Delete button + UIconButton { + iconName: "trash" + baseSize: notificationDelegate.actionButtonSize + colorFg: Colors.mError + onClicked: { + NotificationService.removeFromHistory(notificationId); + } + } + + } + + } + + } + + } + + transform: Translate { + x: notificationDelegate.swipeOffset + } + + Behavior on swipeOffset { + enabled: !notificationDelegate.isSwiping + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + Behavior on opacity { + enabled: notificationDelegate.isRemoving + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + Behavior on height { + enabled: notificationDelegate.isRemoving + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + Behavior on y { + enabled: notificationDelegate.isRemoving + + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + + } + + } + + } + + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/PowerMenuCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/PowerMenuCard.qml new file mode 100644 index 0000000..0e3065f --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/PowerMenuCard.qml @@ -0,0 +1,240 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.UPower +import qs.Components +import qs.Constants +import qs.Services + +UBox { + id: root + + Layout.fillWidth: true + implicitHeight: layout.implicitHeight + Style.marginL * 2 + + ColumnLayout { + id: layout + + anchors.fill: parent + anchors.margins: Style.marginL + spacing: Style.marginL + + // Card Header + RowLayout { + Layout.fillWidth: true + + ColumnLayout { + RowLayout { + id: content + + spacing: Style.marginM + + UImageRounded { + width: Style.baseWidgetSize * 2.4 + height: Style.baseWidgetSize * 2.4 + imagePath: Quickshell.shellDir + "/Assets/Avatar.jpg" + fallbackIcon: "person" + radius: Style.radiusL + } + + ColumnLayout { + spacing: Style.marginXS + + UText { + text: `${HostService.username} @ ${HostService.hostname}` + font.weight: Style.fontWeightBold + font.pointSize: Style.fontSizeL + font.capitalization: Font.Capitalize + } + + UText { + text: "Uptime: " + HostService.uptimeText + font.pointSize: Style.fontSizeM + color: Colors.mOnSurfaceVariant + } + + RowLayout { + id: profileLayout + + spacing: Style.marginXS + + // Performance + UIconButton { + iconName: PowerService.getIcon(PowerProfile.Performance) + enabled: PowerService.available + opacity: enabled ? Style.opacityFull : Style.opacityMedium + colorFg: Colors.mRed + radius: height / 2 + alwaysHover: enabled && PowerService.profile === PowerProfile.Performance + onClicked: PowerService.setProfile(PowerProfile.Performance) + } + + // Balanced + UIconButton { + iconName: PowerService.getIcon(PowerProfile.Balanced) + enabled: PowerService.available + opacity: enabled ? Style.opacityFull : Style.opacityMedium + colorFg: Colors.mBlue + radius: height / 2 + alwaysHover: enabled && PowerService.profile === PowerProfile.Balanced + onClicked: PowerService.setProfile(PowerProfile.Balanced) + } + + // Eco + UIconButton { + iconName: PowerService.getIcon(PowerProfile.PowerSaver) + enabled: PowerService.available + opacity: enabled ? Style.opacityFull : Style.opacityMedium + colorFg: Colors.mGreen + radius: height / 2 + alwaysHover: enabled && PowerService.profile === PowerProfile.PowerSaver + onClicked: PowerService.setProfile(PowerProfile.PowerSaver) + } + + } + + } + + Item { + Layout.fillWidth: true + } + + } + + } + + } + + UDivider { + Layout.fillWidth: true + } + + // Action Tiles + GridLayout { + Layout.fillWidth: true + columns: 3 + rowSpacing: Style.marginM + columnSpacing: Style.marginM + + Repeater { + model: [{ + "name": "Lock", + "icon": "lock", + "isError": false, + "clicked": function() { + PowerService.lock(); + } + }, { + "name": "Logout", + "icon": "logout", + "isError": false, + "clicked": function() { + PowerService.logout(); + } + }, { + "name": "Suspend", + "icon": "moon", + "isError": false, + "clicked": function() { + PowerService.suspend(); + } + }, { + "name": "Hibernate", + "icon": "bed", + "isError": false, + "clicked": function() { + PowerService.hibernate(); + } + }, { + "name": "Reboot", + "icon": "refresh", + "isError": false, + "clicked": function() { + PowerService.reboot(); + } + }, { + "name": "Shutdown", + "icon": "power", + "isError": true, + "clicked": function() { + PowerService.shutdown(); + } + }] + + delegate: Rectangle { + id: tile + + readonly property bool hovered: mouseArea.containsMouse + + Layout.fillWidth: true + Layout.preferredHeight: Style.baseWidgetSize * 2.2 + radius: Style.radiusL + color: hovered ? (modelData.isError ? Colors.mError : Colors.mPrimary) : Colors.mSurface + border.color: hovered ? "transparent" : Colors.mOutline + border.width: 1 + + ColumnLayout { + anchors.centerIn: parent + spacing: Style.marginXS + + UIcon { + Layout.alignment: Qt.AlignHCenter + iconName: modelData.icon + iconSize: Style.fontSizeXL * 1.1 + color: tile.hovered ? (modelData.isError ? Colors.mOnError : Colors.mOnPrimary) : (modelData.isError ? Colors.mError : Colors.mOnSurface) + + Behavior on color { + ColorAnimation { + duration: 150 + } + + } + + } + + UText { + Layout.alignment: Qt.AlignHCenter + text: modelData.name + pointSize: Style.fontSizeS + font.weight: Style.fontWeightMedium + color: tile.hovered ? (modelData.isError ? Colors.mOnError : Colors.mOnPrimary) : Colors.mOnSurfaceVariant + + Behavior on color { + ColorAnimation { + duration: 150 + } + + } + + } + + } + + MouseArea { + id: mouseArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onClicked: { + modelData.clicked(); + } + } + + Behavior on color { + ColorAnimation { + duration: 150 + } + + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WeatherCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WeatherCard.qml new file mode 100644 index 0000000..187cb32 --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WeatherCard.qml @@ -0,0 +1,227 @@ +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import qs.Components +import qs.Constants +import qs.Services +import qs.Utils + +// Weather overview card (placeholder data) +UBox { + id: root + + property int forecastDays: 6 + property bool showLocation: true + property bool showEffects: true + readonly property bool weatherReady: LocationService.data.weather !== null + // Test mode: set to "clear_day", "clear_night", "rain", "snow", "cloud" or "fog" + property string testEffects: "" + // Weather condition detection + readonly property int currentWeatherCode: weatherReady ? LocationService.data.weather.current_weather.weathercode : 0 + readonly property bool isDayTime: weatherReady ? LocationService.data.weather.current_weather.is_day : true + readonly property bool isRaining: testEffects === "rain" || (testEffects === "" && ((currentWeatherCode >= 51 && currentWeatherCode <= 67) || (currentWeatherCode >= 80 && currentWeatherCode <= 82))) + readonly property bool isSnowing: testEffects === "snow" || (testEffects === "" && ((currentWeatherCode >= 71 && currentWeatherCode <= 77) || (currentWeatherCode >= 85 && currentWeatherCode <= 86))) + readonly property bool isCloudy: testEffects === "cloud" || (testEffects === "" && (currentWeatherCode === 3)) + readonly property bool isFoggy: testEffects === "fog" || (testEffects === "" && (currentWeatherCode >= 40 && currentWeatherCode <= 49)) + readonly property bool isClearDay: testEffects === "clear_day" || (testEffects === "" && (currentWeatherCode === 0 && isDayTime)) + readonly property bool isClearNight: testEffects === "clear_night" || (testEffects === "" && (currentWeatherCode === 0 && !isDayTime)) + + implicitHeight: Math.max(100, content.implicitHeight + Style.marginXL * 2) + + // Weather effect layer (rain/snow) + Loader { + id: weatherEffectLoader + + anchors.fill: parent + active: root.showEffects && (root.isRaining || root.isSnowing || root.isCloudy || root.isFoggy || root.isClearDay || root.isClearNight) + + sourceComponent: Item { + // Animated time for shaders + property real shaderTime: 0 + + anchors.fill: parent + + ShaderEffect { + id: weatherEffect + + property var source + property real time: parent.shaderTime + property real itemWidth: weatherEffect.width + property real itemHeight: weatherEffect.height + property color bgColor: root.color + property real cornerRadius: root.isRaining ? 0 : (root.radius - root.border.width) + property real alternative: root.isFoggy + + anchors.fill: parent + // Rain matches content margins, everything else fills the box + anchors.margins: root.isRaining ? Style.marginXL : root.border.width + fragmentShader: { + let shaderName; + if (root.isSnowing) + shaderName = "weather_snow"; + else if (root.isRaining) + shaderName = "weather_rain"; + else if (root.isCloudy || root.isFoggy) + shaderName = "weather_cloud"; + else if (root.isClearDay) + shaderName = "weather_sun"; + else if (root.isClearNight) + shaderName = "weather_stars"; + else + shaderName = ""; + return Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/" + shaderName + ".frag.qsb"); + } + + source: ShaderEffectSource { + sourceItem: content + hideSource: root.isRaining // Only hide for rain (distortion), show for snow + } + + } + + NumberAnimation on shaderTime { + loops: Animation.Infinite + from: 0 + to: 1000 + duration: 100000 + } + + } + + } + + ColumnLayout { + id: content + + anchors.fill: parent + anchors.margins: Style.marginXL + spacing: Style.marginM + clip: true + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + Item { + Layout.preferredWidth: Style.marginXXS + } + + RowLayout { + spacing: Style.marginL + Layout.fillWidth: true + + UIcon { + Layout.alignment: Qt.AlignVCenter + iconName: weatherReady ? LocationService.weatherSymbolFromCode(LocationService.data.weather.current_weather.weathercode, LocationService.data.weather.current_weather.is_day) : "weather-cloud-off" + iconSize: Style.fontSizeXXXL * 1.75 + color: Colors.mPrimary + } + + ColumnLayout { + spacing: Style.marginXXS + + UText { + text: { + // Ensure the name is not too long if one had to specify the country + const loc = SettingsService.location || "Unknown Location"; + const chunks = loc.split(","); + return chunks[0]; + } + pointSize: Style.fontSizeL + font.weight: Style.fontWeightBold + visible: showLocation + } + + RowLayout { + UText { + visible: weatherReady + text: { + if (!weatherReady) + return ""; + + var temp = LocationService.data.weather.current_weather.temperature; + var suffix = "C"; + temp = Math.round(temp); + return `${temp}°${suffix}`; + } + pointSize: showLocation ? Style.fontSizeXL : Style.fontSizeXL * 1.6 + font.weight: Style.fontWeightBold + } + + UText { + text: weatherReady ? `(${LocationService.data.weather.timezone_abbreviation})` : "" + pointSize: Style.fontSizeXS + color: Colors.mOnSurfaceVariant + visible: LocationService.data.weather && showLocation + } + + } + + } + + } + + } + + UDivider { + visible: weatherReady + Layout.fillWidth: true + } + + RowLayout { + visible: weatherReady + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: Style.marginM + + Repeater { + model: weatherReady ? Math.min(root.forecastDays, LocationService.data.weather.daily.time.length) : 0 + + delegate: ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + Item { + Layout.fillWidth: true + } + + UText { + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + text: { + var weatherDate = new Date(LocationService.data.weather.daily.time[index].replace(/-/g, "/")); + // return I18n.locale.toString(weatherDate, "ddd"); + return Qt.formatDate(weatherDate, "ddd"); + } + color: Colors.mOnSurface + } + + UIcon { + Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter + iconName: LocationService.weatherSymbolFromCode(LocationService.data.weather.daily.weathercode[index]) + iconSize: Style.fontSizeXXL * 1.6 + color: Colors.mPrimary + } + + UText { + Layout.alignment: Qt.AlignHCenter + text: { + var max = LocationService.data.weather.daily.temperature_2m_max[index]; + var min = LocationService.data.weather.daily.temperature_2m_min[index]; + max = Math.round(max); + min = Math.round(min); + return `${max}°/${min}°`; + } + pointSize: Style.fontSizeXS + color: Colors.mOnSurfaceVariant + } + + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WifiCard.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WifiCard.qml new file mode 100644 index 0000000..85f992a --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Modules/WifiCard.qml @@ -0,0 +1,559 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import qs.Constants +import qs.Components +import qs.Services +import qs.Utils + +ColumnLayout { + property string passwordSsid: "" + property string passwordInput: "" + property string expandedSsid: "" + + // Error message + Rectangle { + visible: NetworkService.lastError.length > 0 + Layout.fillWidth: true + Layout.preferredHeight: errorRow.implicitHeight + (Style.marginS * 2) + color: Qt.rgba(Colors.mError.r, Colors.mError.g, Colors.mError.b, 0.1) + radius: Style.radiusS + border.width: Math.max(1, Style.borderS) + border.color: Colors.mError + + RowLayout { + id: errorRow + + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + UIcon { + iconName: "warning" + iconSize: Style.fontSizeL + color: Colors.mError + } + + UText { + text: NetworkService.lastError + color: Colors.mError + pointSize: Style.fontSizeS + wrapMode: Text.Wrap + Layout.fillWidth: true + } + + UIconButton { + iconName: "close" + baseSize: Style.baseWidgetSize * 0.6 + onClicked: NetworkService.lastError = "" + } + + } + + } + + // Main content area + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Colors.transparent + + // WiFi disabled state + ColumnLayout { + visible: !SettingsService.wifiEnabled + anchors.fill: parent + spacing: Style.marginS + + Item { + Layout.fillHeight: true + } + + UIcon { + iconName: "wifi-off" + iconSize: 64 + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Wi-Fi Disabled" + pointSize: Style.fontSizeL + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Please enable Wi-Fi to connect to a network." + pointSize: Style.fontSizeS + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + + } + + // Scanning state + ColumnLayout { + visible: SettingsService.wifiEnabled && NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 + anchors.fill: parent + spacing: Style.marginL + + Item { + Layout.fillHeight: true + } + + UBusyIndicator { + running: true + color: Colors.mPrimary + size: Style.baseWidgetSize + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "Searching for networks..." + pointSize: Style.fontSizeM + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + Item { + Layout.fillHeight: true + } + + } + + // Networks list container + UScrollView { + visible: SettingsService.wifiEnabled && (!NetworkService.scanning || Object.keys(NetworkService.networks).length > 0) + anchors.fill: parent + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: Style.marginS + + // Network list + Repeater { + model: { + if (!SettingsService.wifiEnabled) + return []; + + const nets = Object.values(NetworkService.networks); + return nets.sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + + return b.signal - a.signal; + }); + } + + UBox { + Layout.fillWidth: true + implicitHeight: netColumn.implicitHeight + (Style.marginS * 2) + compact: true + + ColumnLayout { + id: netColumn + + width: parent.width - (Style.marginS * 2) + x: Style.marginS + y: Style.marginS + spacing: Style.marginS + + // Main row + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + UIcon { + iconName: NetworkService.signalIcon(modelData.signal) + iconSize: Style.fontSizeXXL + color: modelData.connected ? Colors.mPrimary : Colors.mOnSurface + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + UText { + text: modelData.ssid + pointSize: Style.fontSizeM + font.weight: modelData.connected ? Style.fontWeightBold : Style.fontWeightMedium + color: Colors.mOnSurface + elide: Text.ElideRight + Layout.fillWidth: true + } + + RowLayout { + spacing: Style.marginXS + + UText { + text: `${modelData.signal}%` + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + UText { + text: "•" + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + UText { + text: NetworkService.isSecured(modelData.security) ? modelData.security : "Open" + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + Item { + Layout.preferredWidth: Style.marginXXS + } + + // Update the status badges area (around line 237) + Rectangle { + visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid + color: Colors.mPrimary + radius: height * 0.5 + width: connectedText.implicitWidth + (Style.marginS * 2) + height: connectedText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: connectedText + + anchors.centerIn: parent + text: "Connected" + pointSize: Style.fontSizeXXS + color: Colors.mOnPrimary + } + + } + + Rectangle { + visible: NetworkService.disconnectingFrom === modelData.ssid + color: Colors.mError + radius: height * 0.5 + width: disconnectingText.implicitWidth + (Style.marginS * 2) + height: disconnectingText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: disconnectingText + + anchors.centerIn: parent + text: "disconnecting" + pointSize: Style.fontSizeXXS + color: Colors.mOnPrimary + } + + } + + Rectangle { + visible: NetworkService.forgettingNetwork === modelData.ssid + color: Colors.mError + radius: height * 0.5 + width: forgettingText.implicitWidth + (Style.marginS * 2) + height: forgettingText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: forgettingText + + anchors.centerIn: parent + text: "forgetting" + pointSize: Style.fontSizeXXS + color: Colors.mOnPrimary + } + + } + + Rectangle { + visible: modelData.cached && !modelData.connected && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + color: Colors.transparent + border.color: Colors.mOutline + border.width: Math.max(1, Style.borderS) + radius: height * 0.5 + width: savedText.implicitWidth + (Style.marginS * 2) + height: savedText.implicitHeight + (Style.marginXXS * 2) + + UText { + id: savedText + + anchors.centerIn: parent + text: "saved" + pointSize: Style.fontSizeXXS + color: Colors.mOnSurfaceVariant + } + + } + + } + + } + + // Action area + RowLayout { + spacing: Style.marginS + + UBusyIndicator { + visible: NetworkService.connectingTo === modelData.ssid || NetworkService.disconnectingFrom === modelData.ssid || NetworkService.forgettingNetwork === modelData.ssid + running: visible + color: Colors.mPrimary + size: Style.baseWidgetSize * 0.5 + } + + UIconButton { + visible: (modelData.existing || modelData.cached) && !modelData.connected && NetworkService.connectingTo !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + iconName: "trash" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: expandedSsid = expandedSsid === modelData.ssid ? "" : modelData.ssid + } + + UButton { + visible: !modelData.connected && NetworkService.connectingTo !== modelData.ssid && passwordSsid !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + text: { + if (modelData.existing || modelData.cached) + return "Connect"; + + if (!NetworkService.isSecured(modelData.security)) + return "Connect"; + + return "Enter Password"; + } + outlined: false + fontSize: Style.fontSizeXS + enabled: !NetworkService.connecting + onClicked: { + if (modelData.existing || modelData.cached || !NetworkService.isSecured(modelData.security)) { + NetworkService.connect(modelData.ssid); + } else { + passwordSsid = modelData.ssid; + passwordInput = ""; + expandedSsid = ""; + } + } + } + + UButton { + visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid + text: "Disconnect" + outlined: false + fontSize: Style.fontSizeXS + backgroundColor: Colors.mError + onClicked: NetworkService.disconnect(modelData.ssid) + } + + } + + } + + // Password input + Rectangle { + visible: passwordSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid + Layout.fillWidth: true + height: passwordRow.implicitHeight + Style.marginS * 2 + color: Colors.mSurfaceVariant + border.color: Colors.mOutline + border.width: Math.max(1, Style.borderS) + radius: Style.radiusS + + RowLayout { + id: passwordRow + + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + radius: Style.radiusXS + color: Colors.mSurface + border.color: pwdInput.activeFocus ? Colors.mSecondary : Colors.mOutline + border.width: Math.max(1, Style.borderS) + + TextInput { + id: pwdInput + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Style.marginS + text: passwordInput + font.family: Fonts.sans + font.pointSize: Style.fontSizeS + color: Colors.mOnSurface + echoMode: TextInput.Password + selectByMouse: true + focus: visible + passwordCharacter: "●" + onTextChanged: passwordInput = text + onVisibleChanged: { + if (visible) + forceActiveFocus(); + + } + onAccepted: { + if (text && !NetworkService.connecting) { + NetworkService.connect(passwordSsid, text); + passwordSsid = ""; + passwordInput = ""; + } + } + + UText { + visible: parent.text.length === 0 + anchors.verticalCenter: parent.verticalCenter + text: "Enter Password" + color: Colors.mOnSurfaceVariant + pointSize: Style.fontSizeS + } + + } + + } + + UButton { + text: "Connect" + fontSize: Style.fontSizeXXS + enabled: passwordInput.length > 0 && !NetworkService.connecting + outlined: false + onClicked: { + NetworkService.connect(passwordSsid, passwordInput); + passwordSsid = ""; + passwordInput = ""; + } + } + + UIconButton { + iconName: "close" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: { + passwordSsid = ""; + passwordInput = ""; + } + } + + } + + } + + // Forget network + Rectangle { + visible: expandedSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid + Layout.fillWidth: true + height: forgetRow.implicitHeight + Style.marginS * 2 + color: Colors.mSurfaceVariant + radius: Style.radiusS + border.width: Math.max(1, Style.borderS) + border.color: Colors.mOutline + + RowLayout { + id: forgetRow + + anchors.fill: parent + anchors.margins: Style.marginS + spacing: Style.marginS + + RowLayout { + UIcon { + iconName: "trash" + iconSize: Style.fontSizeL + color: Colors.mError + } + + UText { + text: "Forget this network?" + pointSize: Style.fontSizeS + color: Colors.mError + Layout.fillWidth: true + } + + } + + UButton { + id: forgetButton + + text: "Forget" + fontSize: Style.fontSizeXXS + backgroundColor: Colors.mError + outlined: false + onClicked: { + NetworkService.forget(modelData.ssid); + expandedSsid = ""; + } + } + + UIconButton { + iconName: "close" + baseSize: Style.baseWidgetSize * 0.8 + onClicked: expandedSsid = "" + } + + } + + } + + } + + // Smooth opacity animation + Behavior on opacity { + NumberAnimation { + duration: Style.animationNormal + } + + } + + } + + } + + } + + } + + // Empty state when no networks + ColumnLayout { + visible: SettingsService.wifiEnabled && !NetworkService.scanning && Object.keys(NetworkService.networks).length === 0 + anchors.fill: parent + spacing: Style.marginL + + Item { + Layout.fillHeight: true + } + + UIcon { + iconName: "search" + iconSize: 64 + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UText { + text: "No networks found" + pointSize: Style.fontSizeL + color: Colors.mOnSurfaceVariant + Layout.alignment: Qt.AlignHCenter + } + + UButton { + text: "Scan Again" + icon: "refresh" + Layout.alignment: Qt.AlignHCenter + onClicked: NetworkService.scan() + } + + Item { + Layout.fillHeight: true + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Modules/Sidebar/Sidebars.qml b/config/quickshell/.config/quickshell/Modules/Sidebar/Sidebars.qml new file mode 100644 index 0000000..3e0127e --- /dev/null +++ b/config/quickshell/.config/quickshell/Modules/Sidebar/Sidebars.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Constants +import qs.Modules.Sidebar.Components +import qs.Modules.Sidebar.Modules +import qs.Services + +Variants { + model: Quickshell.screens + + Item { + property var modelData + + SidebarWrap { + screen: modelData + isLeft: true + + contentComponent: ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + WeatherCard { + Layout.fillWidth: true + } + + CalendarMonthCard { + Layout.fillWidth: true + } + + LyricsCard { + Layout.fillWidth: true + Layout.preferredHeight: 100 + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + LyricsControl { + } + + MediaCard { + Layout.fillWidth: true + } + + } + + ConnectionCard { + Layout.fillHeight: true + Layout.fillWidth: true + } + + } + + } + + SidebarWrap { + screen: modelData + isLeft: false + + contentComponent: ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + PowerMenuCard { + Layout.fillWidth: true + } + + NotificationHistoryCard { + Layout.fillWidth: true + Layout.fillHeight: true + } + + } + + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Noctalia/NBox.qml b/config/quickshell/.config/quickshell/Noctalia/NBox.qml deleted file mode 100644 index c753d07..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NBox.qml +++ /dev/null @@ -1,28 +0,0 @@ -import Qt5Compat.GraphicalEffects -import QtQuick -import QtQuick.Layouts -import qs.Constants -import qs.Noctalia - -// Rounded group container using the variant surface color. -// To be used in side panels and settings panes to group fields or buttons. -Rectangle { - id: root - - property bool compact: false - - implicitWidth: childrenRect.width - implicitHeight: childrenRect.height - color: compact ? Color.transparent : Color.mSurfaceVariant - radius: Style.radiusM - layer.enabled: !compact - - layer.effect: DropShadow { - horizontalOffset: 6 - verticalOffset: 6 - radius: 8 - samples: 12 - color: Qt.rgba(0, 0, 0, 0.3) - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NButton.qml b/config/quickshell/.config/quickshell/Noctalia/NButton.qml deleted file mode 100644 index 0d8e4ba..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NButton.qml +++ /dev/null @@ -1,183 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import qs.Constants -import qs.Services - -Rectangle { - id: root - - // Public properties - property string text: "" - property string icon: "" - property string tooltipText - property color backgroundColor: Color.mPrimary - property color textColor: Color.mOnPrimary - property color hoverColor: Color.mTertiary - property bool enabled: true - property real fontSize: Style.fontSizeM - property int fontWeight: Style.fontWeightBold - property string fontFamily: Fonts.primary - property real iconSize: Style.fontSizeL - property bool outlined: false - // Internal properties - property bool hovered: false - property bool pressed: false - - // Signals - signal clicked() - signal rightClicked() - signal middleClicked() - - // Dimensions - implicitWidth: contentRow.implicitWidth + (Style.marginL * 2) - implicitHeight: Math.max(Style.baseWidgetSize, contentRow.implicitHeight + (Style.marginM)) - // Appearance - radius: Style.radiusS - color: { - if (!enabled) - return outlined ? Color.transparent : Qt.lighter(Color.mSurfaceVariant, 1.2); - - if (hovered) - return hoverColor; - - return outlined ? Color.transparent : backgroundColor; - } - border.width: outlined ? Math.max(1, Style.borderS) : 0 - border.color: { - if (!enabled) - return Color.mOutline; - - if (pressed || hovered) - return backgroundColor; - - return outlined ? backgroundColor : Color.transparent; - } - opacity: enabled ? 1 : 0.6 - - // Content - RowLayout { - id: contentRow - - anchors.centerIn: parent - spacing: Style.marginXS - - // Icon (optional) - NIcon { - Layout.alignment: Qt.AlignVCenter - visible: root.icon !== "" - icon: root.icon - pointSize: root.iconSize - color: { - if (!root.enabled) - return Color.mOnSurfaceVariant; - - if (root.outlined) { - if (root.pressed || root.hovered) - return root.backgroundColor; - - return root.backgroundColor; - } - return root.textColor; - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - - } - - // Text - NText { - Layout.alignment: Qt.AlignVCenter - visible: root.text !== "" - text: root.text - pointSize: root.fontSize - font.weight: root.fontWeight - family: root.fontFamily - color: { - if (!root.enabled) - return Color.mOnSurfaceVariant; - - if (root.outlined) { - if (root.hovered) - return root.textColor; - - return root.backgroundColor; - } - return root.textColor; - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - - } - - } - - // Mouse interaction - MouseArea { - id: mouseArea - - anchors.fill: parent - enabled: root.enabled - hoverEnabled: true - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onEntered: { - root.hovered = true; - if (tooltipText) - TooltipService.show(Screen, root, root.tooltipText); - - } - onExited: { - root.hovered = false; - if (tooltipText) - TooltipService.hide(); - - } - onPressed: (mouse) => { - if (tooltipText) - TooltipService.hide(); - - if (mouse.button === Qt.LeftButton) - root.clicked(); - else if (mouse.button == Qt.RightButton) - root.rightClicked(); - else if (mouse.button == Qt.MiddleButton) - root.middleClicked(); - } - onCanceled: { - root.hovered = false; - if (tooltipText) - TooltipService.hide(); - - } - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - - Behavior on border.color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.OutCubic - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NCircleStat.qml b/config/quickshell/.config/quickshell/Noctalia/NCircleStat.qml deleted file mode 100644 index 59dc94f..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NCircleStat.qml +++ /dev/null @@ -1,122 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Noctalia -import qs.Services -import qs.Utils - -// Compact circular statistic display using Layout management -Rectangle { - id: root - - property real value: 0 // 0..100 (or any range visually mapped) - property string icon: "" - property string suffix: "%" - // When nested inside a parent group (NBox), you can make it flat - property bool flat: false - // Scales the internal content (labels, gauge, icon) without changing the - // outer width/height footprint of the component - property real contentScale: 1 - - width: 68 - height: 92 - color: flat ? Color.transparent : Color.mSurface - radius: Style.radiusS - border.color: flat ? Color.transparent : Color.mSurfaceVariant - border.width: flat ? 0 : Math.max(1, Style.borderS) - // Repaint gauge when the bound value changes - onValueChanged: gauge.requestPaint() - - ColumnLayout { - id: mainLayout - - anchors.fill: parent - anchors.margins: Style.marginS * contentScale - spacing: 0 - - // Main gauge container - Item { - id: gaugeContainer - - Layout.fillWidth: true - Layout.fillHeight: true - Layout.alignment: Qt.AlignCenter - Layout.preferredWidth: 68 * contentScale - Layout.preferredHeight: 68 * contentScale - - Canvas { - // 390° (equivalent to 30°) - - id: gauge - - anchors.fill: parent - renderStrategy: Canvas.Cooperative - onPaint: { - const ctx = getContext("2d"); - const w = width, h = height; - const cx = w / 2, cy = h / 2; - const r = Math.min(w, h) / 2 - 5 * contentScale; - // Rotated 90° to the right: gap at the bottom - // Start at 150° and end at 390° (30°) → bottom opening - const start = Math.PI * 5 / 6; - // 150° - const endBg = Math.PI * 13 / 6; - ctx.reset(); - ctx.lineWidth = 6 * contentScale; - // Track uses surfaceVariant for stronger contrast - ctx.strokeStyle = Color.mSurface; - ctx.beginPath(); - ctx.arc(cx, cy, r, start, endBg); - ctx.stroke(); - // Value arc - const ratio = Math.max(0, Math.min(1, root.value / 100)); - const end = start + (endBg - start) * ratio; - ctx.strokeStyle = Color.mPrimary; - ctx.beginPath(); - ctx.arc(cx, cy, r, start, end); - ctx.stroke(); - } - } - - // Percent centered in the circle - NText { - id: valueLabel - - anchors.centerIn: parent - anchors.verticalCenterOffset: -4 * contentScale - text: `${root.value}${root.suffix}` - pointSize: Style.fontSizeM * contentScale - font.weight: Style.fontWeightBold - color: Color.mOnSurface - horizontalAlignment: Text.AlignHCenter - } - - // Tiny circular badge for the icon, positioned inside below the percentage - Rectangle { - id: iconBadge - - width: iconText.implicitWidth + Style.marginXXS - height: width - radius: width / 2 - color: Color.mPrimary - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: valueLabel.bottom - anchors.topMargin: 8 * contentScale - - NIcon { - id: iconText - - anchors.centerIn: parent - icon: root.icon - color: Color.mOnPrimary - pointSize: Style.fontSizeS - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NIcon.qml b/config/quickshell/.config/quickshell/Noctalia/NIcon.qml deleted file mode 100644 index 831aa60..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NIcon.qml +++ /dev/null @@ -1,28 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Constants -import qs.Noctalia - -Text { - id: root - - property string icon: Icons.defaultIcon - property real pointSize: Style.fontSizeL - - visible: (icon !== undefined) && (icon !== "") - text: { - if ((icon === undefined) || (icon === "")) - return ""; - - if (Icons.get(icon) === undefined) { - Logger.warn("Icon", `"${icon}"`, "doesn't exist in the icons font"); - Logger.callStack(); - return Icons.get(Icons.defaultIcon); - } - return Icons.get(icon); - } - font.family: Icons.fontFamily - font.pointSize: root.pointSize - color: Color.mOnSurface - verticalAlignment: Text.AlignVCenter -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NIconButton.qml b/config/quickshell/.config/quickshell/Noctalia/NIconButton.qml deleted file mode 100644 index bebffe4..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NIconButton.qml +++ /dev/null @@ -1,92 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia - -Rectangle { - id: root - - property real baseSize: Style.baseWidgetSize - property string icon - property bool enabled: true - property bool allowClickWhenDisabled: false - property bool hovering: false - property bool compact: false - property color colorBg: Color.mSurfaceVariant - property color colorFg: Color.mPrimary - property color colorBgHover: Color.mTertiary - property color colorFgHover: Color.mOnTertiary - property color colorBorder: Color.transparent - property color colorBorderHover: Color.transparent - - signal entered() - signal exited() - signal clicked() - signal rightClicked() - signal middleClicked() - - implicitWidth: Math.round(baseSize) - implicitHeight: Math.round(baseSize) - opacity: root.enabled ? Style.opacityFull : Style.opacityMedium - color: root.enabled && root.hovering ? colorBgHover : colorBg - radius: width * 0.5 - border.color: root.enabled && root.hovering ? colorBorderHover : colorBorder - border.width: Math.max(1, Style.borderS) - - NIcon { - icon: root.icon - pointSize: Math.max(1, root.compact ? root.width * 0.65 : root.width * 0.48) - color: root.enabled && root.hovering ? colorFgHover : colorFg - // Center horizontally - x: (root.width - width) / 2 - // Center vertically accounting for font metrics - y: (root.height - height) / 2 + (height - contentHeight) / 2 - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - easing.type: Easing.InOutQuad - } - - } - - } - - MouseArea { - // Always enabled to allow hover/tooltip even when the button is disabled - enabled: true - anchors.fill: parent - cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - hoverEnabled: true - onEntered: { - hovering = root.enabled ? true : false; - root.entered(); - } - onExited: { - hovering = false; - root.exited(); - } - onClicked: function(mouse) { - if (!root.enabled && !allowClickWhenDisabled) - return ; - - if (mouse.button === Qt.LeftButton) - root.clicked(); - else if (mouse.button === Qt.RightButton) - root.rightClicked(); - else if (mouse.button === Qt.MiddleButton) - root.middleClicked(); - } - } - - Behavior on color { - ColorAnimation { - duration: Style.animationNormal - easing.type: Easing.InOutQuad - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NImageCircled.qml b/config/quickshell/.config/quickshell/Noctalia/NImageCircled.qml deleted file mode 100644 index d091fe1..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NImageCircled.qml +++ /dev/null @@ -1,85 +0,0 @@ -import QtQuick -import QtQuick.Effects -import Quickshell -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia -import qs.Services - -Rectangle { - id: root - - property string imagePath: "" - property color borderColor: Color.transparent - property real borderWidth: 0 - property string fallbackIcon: "" - property real fallbackIconSize: Style.fontSizeXXL - - color: Color.transparent - radius: parent.width * 0.5 - anchors.margins: Style.marginXXS - - 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 { - property var source - property real imageOpacity: root.opacity - - anchors.fill: parent - fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/Shaders/qsb/circled_image.frag.qsb") - supportsAtlasTextures: false - blending: true - - source: ShaderEffectSource { - sourceItem: img - hideSource: true - live: true - recursive: false - format: ShaderEffectSource.RGBA - } - - } - - // Fallback icon - Loader { - active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "") - anchors.centerIn: parent - - sourceComponent: NIcon { - anchors.centerIn: parent - icon: fallbackIcon - pointSize: fallbackIconSize - 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/config/quickshell/.config/quickshell/Noctalia/NImageRounded.qml b/config/quickshell/.config/quickshell/Noctalia/NImageRounded.qml deleted file mode 100644 index d7dfea3..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NImageRounded.qml +++ /dev/null @@ -1,103 +0,0 @@ -import QtQuick -import QtQuick.Effects -import Quickshell -import Quickshell.Widgets -import qs.Constants -import qs.Noctalia - -Rectangle { - id: root - - property string imagePath: "" - property color borderColor: Color.transparent - property real borderWidth: 0 - property real imageRadius: width * 0.5 - property string fallbackIcon: "" - property real fallbackIconSize: Style.fontSizeXXL - property real scaledRadius: imageRadius - - signal statusChanged(int status) - - color: Color.transparent - radius: scaledRadius - anchors.margins: Style.marginXXS - - 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 - onStatusChanged: root.statusChanged(status) - } - - ShaderEffect { - property var source - // 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 - - anchors.fill: parent - fragmentShader: Qt.resolvedUrl(Quickshell.shellDir + "/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 - color: Color.transparent - z: -1 - } - - source: ShaderEffectSource { - sourceItem: img - hideSource: true - live: true - recursive: false - format: ShaderEffectSource.RGBA - } - - } - - // Fallback icon - Loader { - active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "") - anchors.centerIn: parent - - sourceComponent: NIcon { - anchors.centerIn: parent - icon: fallbackIcon - pointSize: fallbackIconSize - 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/config/quickshell/.config/quickshell/Noctalia/NLabel.qml b/config/quickshell/.config/quickshell/Noctalia/NLabel.qml deleted file mode 100644 index 721761b..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NLabel.qml +++ /dev/null @@ -1,34 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import qs.Constants - -ColumnLayout { - id: root - - property string label: "" - property string description: "" - property color labelColor: Color.mOnSurface - property color descriptionColor: Color.mOnSurfaceVariant - - spacing: Style.marginXXS - Layout.fillWidth: true - - NText { - text: label - pointSize: Style.fontSizeL - font.weight: Style.fontWeightBold - color: labelColor - visible: label !== "" - Layout.fillWidth: true - } - - NText { - text: description - pointSize: Style.fontSizeS - color: descriptionColor - wrapMode: Text.WordWrap - visible: description !== "" - Layout.fillWidth: true - } - -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NPanel.qml b/config/quickshell/.config/quickshell/Noctalia/NPanel.qml deleted file mode 100644 index 107bf44..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NPanel.qml +++ /dev/null @@ -1,459 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Wayland -import qs.Noctalia -import qs.Services -import qs.Constants -import qs.Utils - -Loader { - id: root - - property ShellScreen screen - - property Component panelContent: null - property real preferredWidth: 700 - property real preferredHeight: 900 - property real preferredWidthRatio - property real preferredHeightRatio - property color panelBackgroundColor: Color.mSurface - property bool draggable: false - property var buttonItem: null - property string buttonName: "" - - property bool panelAnchorHorizontalCenter: false - property bool panelAnchorVerticalCenter: false - property bool panelAnchorTop: false - property bool panelAnchorBottom: false - property bool panelAnchorLeft: false - property bool panelAnchorRight: false - - property bool isMasked: false - - // Properties to support positioning relative to the opener (button) - property bool useButtonPosition: false - property point buttonPosition: Qt.point(0, 0) - property int buttonWidth: 0 - property int buttonHeight: 0 - - property bool panelKeyboardFocus: false - property bool backgroundClickEnabled: true - - // Animation properties - readonly property real originalScale: 0.7 - readonly property real originalOpacity: 0.0 - property real scaleValue: originalScale - property real opacityValue: originalOpacity - property real dimmingOpacity: 0 - - signal opened - signal closed - - active: false - asynchronous: true - - Component.onCompleted: { - PanelService.registerPanel(root) - } - - // ----------------------------------------- - // Functions to control background click behavior - function disableBackgroundClick() { - backgroundClickEnabled = false - } - - function enableBackgroundClick() { - // Add a small delay to prevent immediate close after drag release - enableBackgroundClickTimer.restart() - } - - Timer { - id: enableBackgroundClickTimer - interval: 100 - repeat: false - onTriggered: backgroundClickEnabled = true - } - - // ----------------------------------------- - function toggle(buttonItem, buttonName) { - if (!active) { - open(buttonItem, buttonName) - } else { - close() - } - } - - // ----------------------------------------- - function open(buttonItem, buttonName) { - root.buttonItem = buttonItem - root.buttonName = buttonName || "" - - setPosition() - - PanelService.willOpenPanel(root) - - backgroundClickEnabled = true - active = true - root.opened() - } - - // ----------------------------------------- - function close() { - dimmingOpacity = 0 - scaleValue = originalScale - opacityValue = originalOpacity - root.closed() - active = false - useButtonPosition = false - backgroundClickEnabled = true - PanelService.closedPanel(root) - } - - // ----------------------------------------- - function setPosition() { - // If we have a button name, we are landing here from an IPC call. - // IPC calls have no idead on which screen they panel will spawn. - // Resolve the button name to a proper button item now that we have a screen. - if (buttonName !== "" && root.screen !== null) { - buttonItem = BarService.lookupWidget(buttonName, root.screen.name) - } - - // Get the button position if provided - if (buttonItem !== undefined && buttonItem !== null) { - useButtonPosition = true - var itemPos = buttonItem.mapToItem(null, 0, 0) - buttonPosition = Qt.point(itemPos.x, itemPos.y) - buttonWidth = buttonItem.width - buttonHeight = buttonItem.height - } else { - useButtonPosition = false - } - } - - // ----------------------------------------- - sourceComponent: Component { - PanelWindow { - id: panelWindow - - readonly property bool isVertical: false - readonly property bool barIsVisible: (screen !== null) - readonly property real verticalBarWidth: Math.round(Style.barHeight) - - Component.onCompleted: { - Logger.log("NPanel", "Opened", root.objectName) - dimmingOpacity = Style.opacityHeavy - } - - Connections { - target: panelWindow - function onScreenChanged() { - root.screen = screen - - // If called from IPC always reposition if screen is updated - if (buttonName) { - setPosition() - } - // Logger.log("NPanel", "OnScreenChanged", root.screen.name) - } - } - - visible: true - color: Qt.alpha(Color.mShadow, dimmingOpacity) - - WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.namespace: "noctalia-panel" - WlrLayershell.keyboardFocus: root.panelKeyboardFocus ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None - - mask: root.isMasked ? maskRegion : null - - Region { - id: maskRegion - } - - Behavior on color { - ColorAnimation { - duration: Style.animationNormal - } - } - - anchors.top: true - anchors.left: true - anchors.right: true - anchors.bottom: true - - // Close any panel with Esc without requiring focus - Shortcut { - sequences: ["Escape"] - enabled: root.active - onActivated: root.close() - context: Qt.WindowShortcut - } - - // Clicking outside of the rectangle to close - MouseArea { - anchors.fill: parent - enabled: root.backgroundClickEnabled - onClicked: root.close() - } - - // The actual panel's content - Rectangle { - id: panelBackground - color: panelBackgroundColor - radius: Style.radiusL - border.color: Color.mOutline - border.width: Math.max(1, Style.borderS ) - // Dragging support - property bool draggable: root.draggable - property bool isDragged: false - property real manualX: 0 - property real manualY: 0 - width: { - var w - if (preferredWidthRatio !== undefined) { - w = Math.round(Math.max(screen?.width * preferredWidthRatio, preferredWidth) ) - } else { - w = preferredWidth - } - // Clamp width so it is never bigger than the screen - return Math.min(w, screen?.width - Style.marginL * 2) - } - height: { - var h - if (preferredHeightRatio !== undefined) { - h = Math.round(Math.max(screen?.height * preferredHeightRatio, preferredHeight) ) - } else { - h = preferredHeight - } - - // Clamp width so it is never bigger than the screen - return Math.min(h, screen?.height - Style.barHeight - Style.marginL * 2) - } - - scale: root.scaleValue - opacity: root.isMasked ? 0 : root.opacityValue - x: isDragged ? manualX : calculatedX - y: isDragged ? manualY : calculatedY - - // --------------------------------------------- - // Does not account for corners are they are negligible and helps keep the code clean. - // --------------------------------------------- - property real marginTop: { - if (!barIsVisible) { - return 0 - } - return (Style.barHeight + Style.marginS) - } - - property real marginBottom: { - if (!barIsVisible) { - return 0 - } - return Style.marginS - } - - property real marginLeft: { - if (!barIsVisible) { - return 0 - } - return Style.marginS - } - - property real marginRight: { - if (!barIsVisible) { - return 0 - } - return Style.marginS - } - - // --------------------------------------------- - property int calculatedX: { - // Priority to fixed anchoring - if (panelAnchorHorizontalCenter) { - // Center horizontally but respect bar margins - var centerX = Math.round((panelWindow.width - panelBackground.width) / 2) - var minX = marginLeft - var maxX = panelWindow.width - panelBackground.width - marginRight - return Math.round(Math.max(minX, Math.min(centerX, maxX))) - } else if (panelAnchorLeft) { - return marginLeft - } else if (panelAnchorRight) { - return Math.round(panelWindow.width - panelBackground.width - marginRight) - } - - // No fixed anchoring - if (isVertical) { - // Vertical bar - if (barPosition === "right") { - // To the left of the right bar - return Math.round(panelWindow.width - panelBackground.width - marginRight) - } else { - // To the right of the left bar - return marginLeft - } - } else { - // Horizontal bar - if (root.useButtonPosition) { - // Position panel relative to button - var targetX = buttonPosition.x + (buttonWidth / 2) - (panelBackground.width / 2) - // Keep panel within screen bounds - var maxX = panelWindow.width - panelBackground.width - marginRight - var minX = marginLeft - return Math.round(Math.max(minX, Math.min(targetX, maxX))) - } else { - // Fallback to center horizontally - return Math.round((panelWindow.width - panelBackground.width) / 2) - } - } - } - - // --------------------------------------------- - property int calculatedY: { - // Priority to fixed anchoring - if (panelAnchorVerticalCenter) { - // Center vertically but respect bar margins - var centerY = Math.round((panelWindow.height - panelBackground.height) / 2) - var minY = marginTop - var maxY = panelWindow.height - panelBackground.height - marginBottom - return Math.round(Math.max(minY, Math.min(centerY, maxY))) - } else if (panelAnchorTop) { - return marginTop - } else if (panelAnchorBottom) { - return Math.round(panelWindow.height - panelBackground.height - marginBottom) - } - - // No fixed anchoring - if (isVertical) { - // Vertical bar - if (useButtonPosition) { - // Position panel relative to button - var targetY = buttonPosition.y + (buttonHeight / 2) - (panelBackground.height / 2) - // Keep panel within screen bounds - var maxY = panelWindow.height - panelBackground.height - marginBottom - var minY = marginTop - return Math.round(Math.max(minY, Math.min(targetY, maxY))) - } else { - // Fallback to center vertically - return Math.round((panelWindow.height - panelBackground.height) / 2) - } - } else { - return marginTop - } - } - - // Animate in when component is completed - Component.onCompleted: { - root.scaleValue = 1.0 - root.opacityValue = 1.0 - } - - // Reset drag position when panel closes - Connections { - target: root - function onClosed() { - panelBackground.isDragged = false - } - } - - // Prevent closing when clicking in the panel bg - MouseArea { - anchors.fill: parent - } - - // Animation behaviors - Behavior on scale { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutExpo - } - } - - Behavior on opacity { - NumberAnimation { - duration: Style.animationNormal - easing.type: Easing.OutQuad - } - } - - Loader { - id: panelContentLoader - anchors.fill: parent - sourceComponent: root.panelContent - } - - // Handle drag move on the whole panel area - DragHandler { - id: dragHandler - target: null - enabled: panelBackground.draggable - property real dragStartX: 0 - property real dragStartY: 0 - onActiveChanged: { - if (active) { - // Capture current position into manual coordinates BEFORE toggling isDragged - panelBackground.manualX = panelBackground.x - panelBackground.manualY = panelBackground.y - dragStartX = panelBackground.x - dragStartY = panelBackground.y - panelBackground.isDragged = true - if (root.enableBackgroundClick) - root.disableBackgroundClick() - } else { - // Keep isDragged true so we continue using the manual x/y after release - if (root.enableBackgroundClick) - root.enableBackgroundClick() - } - } - onTranslationChanged: { - // Proposed new coordinates from fixed drag origin - var nx = dragStartX + translation.x - var ny = dragStartY + translation.y - - // Calculate gaps so we never overlap the bar on any side - var baseGap = Style.marginS - var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * 2 * Style.marginXL : 0 - var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * 2 * Style.marginXL : 0 - - var insetLeft = baseGap + ((barIsVisible && barPosition === "left") ? (Style.barHeight + floatExtraH) : 0) - var insetRight = baseGap + ((barIsVisible && barPosition === "right") ? (Style.barHeight + floatExtraH) : 0) - var insetTop = baseGap + ((barIsVisible && barPosition === "top") ? (Style.barHeight + floatExtraV) : 0) - var insetBottom = baseGap + ((barIsVisible && barPosition === "bottom") ? (Style.barHeight + floatExtraV) : 0) - - // Clamp within screen bounds accounting for insets - var maxX = panelWindow.width - panelBackground.width - insetRight - var minX = insetLeft - var maxY = panelWindow.height - panelBackground.height - insetBottom - var minY = insetTop - - panelBackground.manualX = Math.round(Math.max(minX, Math.min(nx, maxX))) - panelBackground.manualY = Math.round(Math.max(minY, Math.min(ny, maxY))) - } - } - - // Drag indicator border - Rectangle { - anchors.fill: parent - anchors.margins: 0 - color: Color.transparent - border.color: Color.mPrimary - border.width: Math.max(2, Style.borderL ) - radius: parent.radius - visible: panelBackground.isDragged && dragHandler.active - opacity: 0.8 - z: 3000 - - // Subtle glow effect - Rectangle { - anchors.fill: parent - anchors.margins: 0 - color: Color.transparent - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderS ) - radius: parent.radius - opacity: 0.3 - } - } - } - } - } -} diff --git a/config/quickshell/.config/quickshell/Noctalia/NSlider.qml b/config/quickshell/.config/quickshell/Noctalia/NSlider.qml deleted file mode 100644 index 45f8491..0000000 --- a/config/quickshell/.config/quickshell/Noctalia/NSlider.qml +++ /dev/null @@ -1,152 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import qs.Constants -import qs.Noctalia -import qs.Services -import qs.Utils - -Slider { - id: root - - property var cutoutColor: Color.mSurface - property bool snapAlways: true - property real heightRatio: 0.7 - readonly property real knobDiameter: Math.round((Style.baseWidgetSize * heightRatio) / 2) * 2 - readonly property real trackHeight: Math.round((knobDiameter * 0.4) / 2) * 2 - readonly property real cutoutExtra: Math.round((Style.baseWidgetSize * 0.1) / 2) * 2 - - padding: cutoutExtra / 2 - snapMode: snapAlways ? Slider.SnapAlways : Slider.SnapOnRelease - implicitHeight: Math.max(trackHeight, knobDiameter) - - background: Rectangle { - x: root.leftPadding - y: root.topPadding + root.availableHeight / 2 - height / 2 - implicitWidth: Style.sliderWidth - implicitHeight: trackHeight - width: root.availableWidth - height: implicitHeight - radius: height / 2 - color: Qt.alpha(Color.mSurface, 0.5) - border.color: Qt.alpha(Color.mOutline, 0.5) - border.width: Math.max(1, Style.borderS) - - // A container composite shape that puts a semicircle on the end - Item { - id: activeTrackContainer - - width: root.visualPosition * parent.width - height: parent.height - - // The rounded end cap made from a rounded rectangle - Rectangle { - width: parent.height - height: parent.height - radius: width / 2 - color: Qt.darker(Color.mPrimary, 1.2) //starting color of gradient - } - - // The main rectangle - Rectangle { - x: parent.height / 2 - width: parent.width - x // Fills the rest of the container - height: parent.height - radius: 0 - - // Animated gradient fill - gradient: Gradient { - orientation: Gradient.Horizontal - - GradientStop { - position: 0 - color: Qt.darker(Color.mPrimary, 1.2) - - Behavior on color { - ColorAnimation { - duration: 300 - } - - } - - } - - GradientStop { - position: 0.5 - color: Color.mPrimary - - SequentialAnimation on position { - loops: Animation.Infinite - - NumberAnimation { - from: 0.3 - to: 0.7 - duration: 2000 - easing.type: Easing.InOutSine - } - - NumberAnimation { - from: 0.7 - to: 0.3 - duration: 2000 - easing.type: Easing.InOutSine - } - - } - - } - - GradientStop { - position: 1 - color: Qt.lighter(Color.mPrimary, 1.2) - } - - } - - } - - } - - // Circular cutout - Rectangle { - id: knobCutout - - implicitWidth: knobDiameter + cutoutExtra - implicitHeight: knobDiameter + cutoutExtra - radius: width / 2 - color: root.cutoutColor !== undefined ? root.cutoutColor : Color.mSurface - x: root.leftPadding + root.visualPosition * (root.availableWidth - root.knobDiameter) - cutoutExtra - anchors.verticalCenter: parent.verticalCenter - } - - } - - handle: Item { - implicitWidth: knobDiameter - implicitHeight: knobDiameter - x: root.leftPadding + root.visualPosition * (root.availableWidth - width) - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - id: knob - - implicitWidth: knobDiameter - implicitHeight: knobDiameter - radius: width / 2 - color: root.pressed ? Color.mTertiary : Color.mSurface - border.color: Color.mPrimary - border.width: Math.max(1, Style.borderL) - anchors.centerIn: parent - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } - - } - - } - - } - -} diff --git a/config/quickshell/.config/quickshell/Services/AudioService.qml b/config/quickshell/.config/quickshell/Services/AudioService.qml index 0b63944..6a4ebb4 100644 --- a/config/quickshell/.config/quickshell/Services/AudioService.qml +++ b/config/quickshell/.config/quickshell/Services/AudioService.qml @@ -5,142 +5,632 @@ import Quickshell import Quickshell.Io import Quickshell.Services.Pipewire import qs.Utils +import qs.Services Singleton { id: root - readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => { - if (!node.isStream) { - if (node.isSink) { - acc.sinks.push(node) - } else if (node.audio) { - acc.sources.push(node) - } - } - return acc - }, { - "sources": [], - "sinks": [] - }) + // Rate limiting for volume feedback (minimum 100ms between sounds) + property var lastVolumeFeedbackTime: 0 + readonly property int minVolumeFeedbackInterval: 100 - readonly property PwNode sink: Pipewire.defaultAudioSink - readonly property PwNode source: Pipewire.defaultAudioSource - readonly property list sinks: nodes.sinks - readonly property list sources: nodes.sources + // Devices + readonly property PwNode sink: Pipewire.ready ? Pipewire.defaultAudioSink : null + readonly property PwNode source: validatedSource + readonly property bool hasInput: !!source + readonly property list sinks: deviceNodes.sinks + readonly property list sources: deviceNodes.sources - // Volume [0..1] is readonly from outside - readonly property alias volume: root._volume - property real _volume: sink?.audio?.volume ?? 0 + readonly property real epsilon: 0.005 - readonly property alias muted: root._muted - property bool _muted: !!sink?.audio?.muted + // Fallback state sourced from wpctl when PipeWire node values go stale. + property bool wpctlAvailable: false + property bool wpctlStateValid: false + property real wpctlOutputVolume: 0 + property bool wpctlOutputMuted: true - // Input volume [0..1] is readonly from outside - readonly property alias inputVolume: root._inputVolume - property real _inputVolume: source?.audio?.volume ?? 0 + function clampOutputVolume(vol: real): real { + const maxVolume = 1.0; + if (vol === undefined || isNaN(vol)) { + return 0; + } + return Math.max(0, Math.min(maxVolume, vol)); + } - readonly property alias inputMuted: root._inputMuted - property bool _inputMuted: !!source?.audio?.muted + function refreshWpctlOutputState(): void { + if (!wpctlAvailable || wpctlStateProcess.running) { + return; + } + wpctlStateProcess.command = ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"]; + wpctlStateProcess.running = true; + } + + function applyWpctlOutputState(raw: string): bool { + const text = String(raw || "").trim(); + const match = text.match(/Volume:\s*([0-9]*\.?[0-9]+)/i); + if (!match || match.length < 2) { + return false; + } + + const parsedVolume = Number(match[1]); + if (isNaN(parsedVolume)) { + return false; + } + + wpctlOutputVolume = clampOutputVolume(parsedVolume); + wpctlOutputMuted = /\[MUTED\]/i.test(text); + wpctlStateValid = true; + return true; + } + + // Output volume (prefer wpctl state when available) + readonly property real volume: { + if (wpctlAvailable && wpctlStateValid) { + return clampOutputVolume(wpctlOutputVolume); + } + + if (!sink?.audio) + return 0; + + return clampOutputVolume(sink.audio.volume); + } + readonly property bool muted: { + if (wpctlAvailable && wpctlStateValid) { + return wpctlOutputMuted; + } + return sink?.audio?.muted ?? true; + } + + // Input Volume - read directly from device + readonly property real inputVolume: { + if (!source?.audio) + return 0; + const vol = source.audio.volume; + if (vol === undefined || isNaN(vol)) + return 0; + const maxVolume = 1.0; + return Math.max(0, Math.min(maxVolume, vol)); + } + readonly property bool inputMuted: source?.audio?.muted ?? true + + // Allow callers to skip the next OSD notification when they are already + // presenting volume state (e.g. the Audio Panel UI). We track this as a short + // time window so suppression applies to every monitor, not just the first one + // that receives the signal. + property double outputOSDSuppressedUntilMs: 0 + property double inputOSDSuppressedUntilMs: 0 + + function suppressOutputOSD(durationMs = 400) { + const target = Date.now() + durationMs; + outputOSDSuppressedUntilMs = Math.max(outputOSDSuppressedUntilMs, target); + } + + function suppressInputOSD(durationMs = 400) { + const target = Date.now() + durationMs; + inputOSDSuppressedUntilMs = Math.max(inputOSDSuppressedUntilMs, target); + } + + function consumeOutputOSDSuppression(): bool { + return Date.now() < outputOSDSuppressedUntilMs; + } + + function consumeInputOSDSuppression(): bool { + return Date.now() < inputOSDSuppressedUntilMs; + } readonly property real stepVolume: 5 / 100.0 + // Filtered device nodes (non-stream sinks and sources) + readonly property var deviceNodes: Pipewire.ready ? Pipewire.nodes.values.reduce((acc, node) => { + if (!node.isStream) { + // Filter out quickshell nodes (unlikely to be devices, but for consistency) + const name = node.name || ""; + const mediaName = (node.properties && node.properties["media.name"]) || ""; + if (name === "quickshell" || mediaName === "quickshell") { + return acc; + } + + if (node.isSink) { + acc.sinks.push(node); + } else if (node.audio) { + acc.sources.push(node); + } + } + return acc; + }, { + "sources": [], + "sinks": [] + }) : { + "sources": [], + "sinks": [] + } + + // Validated source (ensures it's a proper audio source, not a sink) + readonly property PwNode validatedSource: { + if (!Pipewire.ready) { + return null; + } + const raw = Pipewire.defaultAudioSource; + if (!raw || raw.isSink || !raw.audio) { + return null; + } + // Optional: check type if available (type reflects media.class per docs) + if (raw.type && typeof raw.type === "string" && !raw.type.startsWith("Audio/Source")) { + return null; + } + return raw; + } + + // Internal state for feedback loop prevention + property bool isSettingOutputVolume: false + property bool isSettingInputVolume: false + + // Bind default sink and source to ensure their properties are available + PwObjectTracker { + id: sinkTracker + objects: root.sink ? [root.sink] : [] + } + + PwObjectTracker { + id: sourceTracker + objects: root.source ? [root.source] : [] + } + + // Track links to the default sink to find active streams + PwNodeLinkTracker { + id: sinkLinkTracker + node: root.sink + } + + // Find application streams that are connected to the default sink + readonly property var appStreams: { + if (!Pipewire.ready || !root.sink) { + return []; + } + + var connectedStreamIds = {}; + var connectedStreams = []; + + // Use PwNodeLinkTracker to get properly bound link groups + if (!sinkLinkTracker.linkGroups) { + return []; + } + + var linkGroupsCount = 0; + if (sinkLinkTracker.linkGroups.length !== undefined) { + linkGroupsCount = sinkLinkTracker.linkGroups.length; + } else if (sinkLinkTracker.linkGroups.count !== undefined) { + linkGroupsCount = sinkLinkTracker.linkGroups.count; + } else { + return []; + } + + if (linkGroupsCount === 0) { + return []; + } + + var intermediateNodeIds = {}; + var nodesToCheck = []; + + for (var i = 0; i < linkGroupsCount; i++) { + var linkGroup; + if (sinkLinkTracker.linkGroups.get) { + linkGroup = sinkLinkTracker.linkGroups.get(i); + } else { + linkGroup = sinkLinkTracker.linkGroups[i]; + } + + if (!linkGroup || !linkGroup.source) { + continue; + } + + var sourceNode = linkGroup.source; + + // Filter out quickshell + const name = sourceNode.name || ""; + const mediaName = (sourceNode.properties && sourceNode.properties["media.name"]) || ""; + if (name === "quickshell" || mediaName === "quickshell") { + continue; + } + + // If it's a stream node, add it directly + if (sourceNode.isStream && sourceNode.audio) { + if (!connectedStreamIds[sourceNode.id]) { + connectedStreamIds[sourceNode.id] = true; + connectedStreams.push(sourceNode); + } + } else { + // Not a stream - this is an intermediate node, track it + intermediateNodeIds[sourceNode.id] = true; + nodesToCheck.push(sourceNode); + } + } + + // If we found intermediate nodes, we need to find streams connected to them + if (nodesToCheck.length > 0 || connectedStreams.length === 0) { + try { + var allNodes = Pipewire.nodes.values || []; + + // Find all stream nodes + for (var j = 0; j < allNodes.length; j++) { + var node = allNodes[j]; + if (!node || !node.isStream || !node.audio) { + continue; + } + + // Filter out quickshell + const nodeName = node.name || ""; + const nodeMediaName = (node.properties && node.properties["media.name"]) || ""; + if (nodeName === "quickshell" || nodeMediaName === "quickshell") { + continue; + } + + var streamId = node.id; + if (connectedStreamIds[streamId]) { + continue; + } + + if (Object.keys(intermediateNodeIds).length > 0) { + connectedStreamIds[streamId] = true; + connectedStreams.push(node); + } else if (connectedStreams.length === 0) { + connectedStreamIds[streamId] = true; + connectedStreams.push(node); + } + } + } catch (e) {} + } + + return connectedStreams; + } + + // Bind all devices to ensure their properties are available PwObjectTracker { objects: [...root.sinks, ...root.sources] } - Connections { - target: sink?.audio ? sink?.audio : null - - function onVolumeChanged() { - var vol = (sink?.audio.volume ?? 0) - if (isNaN(vol)) { - vol = 0 - } - root._volume = vol - } - - function onMutedChanged() { - root._muted = (sink?.audio.muted ?? true) - Logger.log("AudioService", "OnMuteChanged:", root._muted) - } + Component.onCompleted: { + wpctlAvailabilityProcess.running = true; } Connections { - target: source?.audio ? source?.audio : null - - function onVolumeChanged() { - var vol = (source?.audio.volume ?? 0) - if (isNaN(vol)) { - vol = 0 + target: root + function onSinkChanged() { + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); } - root._inputVolume = vol - } - - function onMutedChanged() { - root._inputMuted = (source?.audio.muted ?? true) - Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted) } } + Timer { + id: wpctlPollTimer + // Safety net only; regular updates are event-driven from sink audio signals. + interval: 20000 + running: root.wpctlAvailable + repeat: true + onTriggered: root.refreshWpctlOutputState() + } + + Process { + id: wpctlAvailabilityProcess + command: ["sh", "-c", "command -v wpctl"] + running: false + + onExited: function (code) { + root.wpctlAvailable = (code === 0); + root.wpctlStateValid = false; + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); + } + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + Process { + id: wpctlStateProcess + running: false + + onExited: function (code) { + if (code !== 0 || !root.applyWpctlOutputState(stdout.text)) { + root.wpctlStateValid = false; + } + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + Process { + id: wpctlSetVolumeProcess + running: false + + onExited: function (code) { + root.isSettingOutputVolume = false; + if (code !== 0) { + Logger.w("AudioService", "wpctl set-volume failed, falling back to PipeWire node audio"); + if (root.sink?.audio) { + root.sink.audio.muted = false; + root.sink.audio.volume = root.clampOutputVolume(root.wpctlOutputVolume); + } + } + root.refreshWpctlOutputState(); + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + Process { + id: wpctlSetMuteProcess + running: false + + onExited: function (_code) { + root.refreshWpctlOutputState(); + } + + stdout: StdioCollector {} + stderr: StdioCollector {} + } + + // Watch output device changes for clamping + Connections { + target: sink?.audio ?? null + + function onVolumeChanged() { + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); + } + + // Ignore volume changes if we're the one setting it (to prevent feedback loop) + if (root.isSettingOutputVolume) { + return; + } + + if (!root.sink?.audio) { + return; + } + + const vol = root.sink.audio.volume; + if (vol === undefined || isNaN(vol)) { + return; + } + + const maxVolume = 1.0; + + // If volume exceeds max, clamp it (but only if we didn't just set it) + if (vol > maxVolume) { + root.isSettingOutputVolume = true; + Qt.callLater(() => { + if (root.sink?.audio && root.sink.audio.volume > maxVolume) { + root.sink.audio.volume = maxVolume; + } + root.isSettingOutputVolume = false; + }); + } + } + + function onMutedChanged() { + if (root.wpctlAvailable) { + root.refreshWpctlOutputState(); + } + } + } + + // Watch input device changes for clamping + Connections { + target: source?.audio ?? null + + function onVolumeChanged() { + // Ignore volume changes if we're the one setting it (to prevent feedback loop) + if (root.isSettingInputVolume) { + return; + } + + if (!root.source?.audio) { + return; + } + + const vol = root.source.audio.volume; + if (vol === undefined || isNaN(vol)) { + return; + } + + const maxVolume = 1.0; + + // If volume exceeds max, clamp it (but only if we didn't just set it) + if (vol > maxVolume) { + root.isSettingInputVolume = true; + Qt.callLater(() => { + if (root.source?.audio && root.source.audio.volume > maxVolume) { + root.source.audio.volume = maxVolume; + } + root.isSettingInputVolume = false; + }); + } + } + } + + // Output Control function increaseVolume() { - setVolume(volume + stepVolume) + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + return; + } + const maxVolume = 1.0; + if (volume >= maxVolume) { + return; + } + setVolume(Math.min(maxVolume, volume + stepVolume)); } function decreaseVolume() { - setVolume(volume - stepVolume) + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + return; + } + if (volume <= 0) { + return; + } + setVolume(Math.max(0, volume - stepVolume)); } function setVolume(newVolume: real) { - if (sink?.ready && sink?.audio) { - // Clamp it accordingly - sink.audio.muted = false - sink.audio.volume = Math.max(0, Math.min(1.0, newVolume)) - } else { - Logger.warn("AudioService", "No sink available") + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + Logger.w("AudioService", "No sink available or not ready"); + return; } + + const clampedVolume = clampOutputVolume(newVolume); + const delta = Math.abs(clampedVolume - volume); + if (delta < root.epsilon) { + return; + } + + if (wpctlAvailable) { + if (wpctlSetVolumeProcess.running) { + return; + } + + isSettingOutputVolume = true; + wpctlOutputMuted = false; + wpctlOutputVolume = clampedVolume; + wpctlStateValid = true; + + const volumePct = Math.round(clampedVolume * 10000) / 100; + wpctlSetVolumeProcess.command = ["sh", "-c", "wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 && wpctl set-volume @DEFAULT_AUDIO_SINK@ " + volumePct + "%"]; + wpctlSetVolumeProcess.running = true; + + return; + } + + if (!sink?.ready || !sink?.audio) { + Logger.w("AudioService", "No sink available or not ready"); + return; + } + + // Set flag to prevent feedback loop, then set the actual volume + isSettingOutputVolume = true; + sink.audio.muted = false; + sink.audio.volume = clampedVolume; + + + // Clear flag after a short delay to allow external changes to be detected + Qt.callLater(() => { + isSettingOutputVolume = false; + }); } function setOutputMuted(muted: bool) { - if (sink?.ready && sink?.audio) { - sink.audio.muted = muted - } else { - Logger.warn("AudioService", "No sink available") + if (!Pipewire.ready || (!sink?.audio && !wpctlAvailable)) { + Logger.w("AudioService", "No sink available or Pipewire not ready"); + return; } + + if (wpctlAvailable) { + if (wpctlSetMuteProcess.running) { + return; + } + + wpctlOutputMuted = muted; + wpctlStateValid = true; + wpctlSetMuteProcess.command = ["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", muted ? "1" : "0"]; + wpctlSetMuteProcess.running = true; + return; + } + + sink.audio.muted = muted; + } + + function getOutputIcon() { + if (muted) + return "volume-mute"; + + const maxVolume = 1.0; + const clampedVolume = Math.max(0, Math.min(volume, maxVolume)); + + // Show volume-x icon when volume is effectively 0% (within rounding threshold) + if (clampedVolume < root.epsilon) { + return "volume-x"; + } + if (clampedVolume <= 0.5) { + return "volume-low"; + } + return "volume-high"; + } + + // Input Control + function increaseInputVolume() { + if (!Pipewire.ready || !source?.audio) { + return; + } + const maxVolume = 1.0; + if (inputVolume >= maxVolume) { + return; + } + setInputVolume(Math.min(maxVolume, inputVolume + stepVolume)); + } + + function decreaseInputVolume() { + if (!Pipewire.ready || !source?.audio) { + return; + } + setInputVolume(Math.max(0, inputVolume - stepVolume)); } function setInputVolume(newVolume: real) { - if (source?.ready && source?.audio) { - // Clamp it accordingly - source.audio.muted = false - source.audio.volume = Math.max(0, Math.min(1.0, newVolume)) - } else { - Logger.warn("AudioService", "No source available") + if (!Pipewire.ready || !source?.ready || !source?.audio) { + Logger.w("AudioService", "No source available or not ready"); + return; } + + const maxVolume = 1.0; + const clampedVolume = Math.max(0, Math.min(maxVolume, newVolume)); + const delta = Math.abs(clampedVolume - source.audio.volume); + if (delta < root.epsilon) { + return; + } + + // Set flag to prevent feedback loop, then set the actual volume + isSettingInputVolume = true; + source.audio.muted = false; + source.audio.volume = clampedVolume; + + // Clear flag after a short delay to allow external changes to be detected + Qt.callLater(() => { + isSettingInputVolume = false; + }); } function setInputMuted(muted: bool) { - if (source?.ready && source?.audio) { - source.audio.muted = muted - } else { - Logger.warn("AudioService", "No source available") + if (!Pipewire.ready || !source?.audio) { + Logger.w("AudioService", "No source available or Pipewire not ready"); + return; } + + source.audio.muted = muted; } + function getInputIcon() { + if (inputMuted || inputVolume <= Number.EPSILON) { + return "microphone-mute"; + } + return "microphone"; + } + + // Device Selection function setAudioSink(newSink: PwNode): void { - Pipewire.preferredDefaultAudioSink = newSink - // Volume is changed by the sink change - root._volume = newSink?.audio?.volume ?? 0 - root._muted = !!newSink?.audio?.muted + if (!Pipewire.ready) { + Logger.w("AudioService", "Pipewire not ready"); + return; + } + Pipewire.preferredDefaultAudioSink = newSink; } function setAudioSource(newSource: PwNode): void { - Pipewire.preferredDefaultAudioSource = newSource - // Volume is changed by the source change - root._inputVolume = newSource?.audio?.volume ?? 0 - root._inputMuted = !!newSource?.audio?.muted - } - - function toggleMute() { - setOutputMuted(!muted) + if (!Pipewire.ready) { + Logger.w("AudioService", "Pipewire not ready"); + return; + } + Pipewire.preferredDefaultAudioSource = newSource; } } diff --git a/config/quickshell/.config/quickshell/Services/BackgroundService.qml b/config/quickshell/.config/quickshell/Services/BackgroundService.qml new file mode 100644 index 0000000..dfa015a --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/BackgroundService.qml @@ -0,0 +1,94 @@ +import QtQuick +import Quickshell +import qs.Constants +import qs.Services +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property string backgroundWidth: "2560" + property string backgroundHeight: "1440" + property string cachedPath: "" + property string cachedBlurredPath: "" + property string previewPath: "" + // Preserved for getBlurredOverview + property string tintColor: Colors.mSurface + property bool isDarkMode: false + + function loadBackground() { + if (!SettingsService.backgroundPath) { + Logger.w("BackgroundService", "No background path set, skipping loading background."); + return ; + } + ImageCacheService.getLarge(SettingsService.backgroundPath, backgroundWidth, backgroundHeight, function(path) { + if (!path) { + Logger.e("BackgroundService", "Failed to load background image from path: " + SettingsService.backgroundPath); + return ; + } + cachedPath = path; + Logger.i("BackgroundService", "Loaded background image as cached path: " + path); + ImageCacheService.getBlurredOverview(SettingsService.backgroundPath, backgroundWidth, backgroundHeight, tintColor, isDarkMode, function(blurredPath) { + if (!blurredPath) { + Logger.e("BackgroundService", "Failed to load blurred background image from path: " + SettingsService.backgroundPath); + return ; + } + cachedBlurredPath = blurredPath; + Logger.i("BackgroundService", "Loaded blurred background image as cached blurred path: " + blurredPath); + }); + }); + } + + function previewWallpaper(path) { + if (!path) { + previewPath = ""; + return ; + } + ImageCacheService.checkFileExists(path, function(exists) { + if (!exists) { + previewPath = ""; + return ; + } + previewPath = path; + }); + } + + function setWallpaper(path) { + if (!path) + return ; + + previewPath = ""; // clear preview path + ImageCacheService.checkFileExists(path, function(exists) { + if (!exists) + return ; + + SettingsService.backgroundPath = path; + loadWallpaperDebouncer.start(); + }); + } + + Component.onCompleted: { + loadWallpaperDebouncer.start(); + } + + Connections { + function onBackgroundPathChanged() { + loadWallpaperDebouncer.start(); + } + + target: SettingsService + } + + Timer { + id: loadWallpaperDebouncer + + interval: 200 + running: false + repeat: false + onTriggered: { + root.loadBackground(); + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/BarService.qml b/config/quickshell/.config/quickshell/Services/BarService.qml new file mode 100644 index 0000000..8539445 --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/BarService.qml @@ -0,0 +1,62 @@ +import QtQuick +import Quickshell +import qs.Services +pragma Singleton + +Singleton { + property var _leftSideBarMap: ({ + }) + property var _rightSideBarMap: ({ + }) + property var _currentLeftSidebar: null + property var _currentRightSidebar: null + // property int leftOffset: _currentLeftSidebar?.barWidth ?? 0 + // property int rightOffset: _currentRightSidebar?.barWidth ?? 0 + property bool focusMode: Niri.hasFocusedWindow || _currentLeftSidebar !== null || _currentRightSidebar !== null + + function getLeftSidebar(screen) { + return _leftSideBarMap[screen] || null; + } + + function getRightSidebar(screen) { + return _rightSideBarMap[screen] || null; + } + + // screen should be string, like "eDP-1" + function registerLeft(screen, sidebar) { + _leftSideBarMap[screen] = sidebar; + } + + function registerRight(screen, sidebar) { + _rightSideBarMap[screen] = sidebar; + } + + function toggleLeft() { + if (_currentLeftSidebar) { + _currentLeftSidebar.close(); + _currentLeftSidebar = null; + } else { + const screen = Niri.focusedOutput; + const sidebar = _leftSideBarMap[screen]; + if (sidebar) { + sidebar.open(); + _currentLeftSidebar = sidebar; + } + } + } + + function toggleRight() { + if (_currentRightSidebar) { + _currentRightSidebar.close(); + _currentRightSidebar = null; + } else { + const screen = Niri.focusedOutput; + const sidebar = _rightSideBarMap[screen]; + if (sidebar) { + sidebar.open(); + _currentRightSidebar = sidebar; + } + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/BatteryService.qml b/config/quickshell/.config/quickshell/Services/BatteryService.qml new file mode 100644 index 0000000..6f4191d --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/BatteryService.qml @@ -0,0 +1,285 @@ +pragma Singleton +import QtQml +import QtQuick + +import Quickshell +import Quickshell.Io +import Quickshell.Services.UPower +import qs.Utils +import qs.Services + +Singleton { + id: root + + readonly property var primaryDevice: _laptopBattery || _bluetoothBattery || null // Primary battery device (prioritizes laptop over Bluetooth) + readonly property real batteryPercentage: getPercentage(primaryDevice) + readonly property bool batteryCharging: isCharging(primaryDevice) + readonly property bool batteryPluggedIn: isPluggedIn(primaryDevice) + readonly property bool batteryReady: isDeviceReady(primaryDevice) + readonly property bool batteryPresent: isDevicePresent(primaryDevice) + readonly property real warningThreshold: Settings.data.systemMonitor.batteryWarningThreshold + readonly property real criticalThreshold: Settings.data.systemMonitor.batteryCriticalThreshold + readonly property string batteryIcon: getIcon(batteryPercentage, batteryCharging, batteryPluggedIn, batteryReady) + + readonly property var laptopBatteries: UPower.devices.values.filter(d => d.isLaptopBattery).sort((x, y) => { + // Force DisplayDevice to the top + if (x.nativePath.includes("DisplayDevice")) + return -1; + if (y.nativePath.includes("DisplayDevice")) + return 1; + + // Standard string comparison works for BAT0 vs BAT1 + return x.nativePath.localeCompare(y.nativePath, undefined, { + numeric: true + }); + }) + + readonly property var bluetoothBatteries: { + var list = []; + var btArray = BluetoothService.devices?.values || []; + for (var i = 0; i < btArray.length; i++) { + var btd = btArray[i]; + if (btd && btd.connected && btd.batteryAvailable) { + list.push(btd); + } + } + return list; + } + + readonly property var _laptopBattery: UPower.displayDevice.isPresent ? UPower.displayDevice : (laptopBatteries.length > 0 ? laptopBatteries[0] : null) + readonly property var _bluetoothBattery: bluetoothBatteries.length > 0 ? bluetoothBatteries[0] : null + + property var deviceModel: { + var model = [ + { + "key": "__default__", + "name": I18n.tr("bar.battery.device-default") + } + ]; + const devices = UPower.devices?.values || []; + for (let d of devices) { + if (!d || d.type === UPowerDeviceType.LinePower) { + continue; + } + model.push({ + key: d.nativePath || "", + name: d.model || d.nativePath || I18n.tr("common.unknown") + }); + } + return model; + } + + property var _hasNotified: ({}) + + function findDevice(nativePath) { + if (!nativePath || nativePath === "__default__" || nativePath === "DisplayDevice") { + return _laptopBattery; + } + + if (!UPower.devices) { + return null; + } + + const devices = UPower.devices?.values || []; + for (let d of devices) { + if (d && d.nativePath === nativePath) { + if (d.type === UPowerDeviceType.LinePower) { + continue; + } + return d; + } + } + return null; + } + + function isDevicePresent(device) { + if (!device) { + return false; + } + + // Handle Bluetooth devices (identified by having batteryAvailable property) + if (device.batteryAvailable !== undefined) { + return device.connected === true; + } + + // Handle UPower devices + if (device.type !== undefined) { + if (device.type === UPowerDeviceType.Battery && device.isPresent !== undefined) { + return device.isPresent === true; + } + // Fallback for non-battery UPower devices or if isPresent is missing + return device.ready && device.percentage !== undefined; + } + return false; + } + + function isDeviceReady(device) { + if (!isDevicePresent(device)) { + return false; + } + if (device.batteryAvailable !== undefined) { + return device.battery !== undefined; + } + return device.ready && device.percentage !== undefined; + } + + function getPercentage(device) { + if (!device) { + return -1; + } + if (device.batteryAvailable !== undefined) { + return Math.round((device.battery || 0) * 100); + } + return Math.round((device.percentage || 0) * 100); + } + + function isCharging(device) { + if (!device || isBluetoothDevice(device)) { + // Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?! + return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely. + } + if (device.state !== undefined) { + return device.state === UPowerDeviceState.Charging; + } + return false; + } + + function isPluggedIn(device) { + if (!device || isBluetoothDevice(device)) { + // Tracking bluetooth devices can charge or not is a loop hole, none of my devices has it, even if it possible?! + return false; // Assuming not charging until someone/quickshell brings a way to do pretty unlikely. + } + if (device.state !== undefined) { + return device.state === UPowerDeviceState.FullyCharged || device.state === UPowerDeviceState.PendingCharge; + } + return false; + } + + function isCriticalBattery(device) { + return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= criticalThreshold; + } + + function isLowBattery(device) { + return (!isCharging(device) && !isPluggedIn(device)) && getPercentage(device) <= warningThreshold && getPercentage(device) > criticalThreshold; + } + + function isBluetoothDevice(device) { + if (!device) { + return false; + } + // Check for Quickshell Bluetooth device property + if (device.batteryAvailable !== undefined) { + return true; + } + // Check for UPower device path indicating it's a Bluetooth device + if (device.nativePath && (device.nativePath.includes("bluez") || device.nativePath.includes("bluetooth"))) { + return true; + } + return false; + } + + function getDeviceName(device) { + if (!isDeviceReady(device)) { + return ""; + } + + if (!isBluetoothDevice(device) && device.isLaptopBattery) { + // If there is more than one battery explicitly name them + // Logger.e("BatteryDebug", "Available Battery count: " + laptopBatteries.length); // can be useful for debugging + if (laptopBatteries.length > 1 && device.nativePath) { + if (device.nativePath === "DisplayDevice") { + return "All batteries (combined)"; // TODO: i18n + } + var match = device.nativePath.match(/(\d+)$/); + if (match) { + // In case of 2 batteries: bat0 => bat1 bat1 => bat2 + return I18n.tr("common.battery") + " " + (parseInt(match[1]) + 1); // Append numbers + } + } + // Return Battery if there is only one + return I18n.tr("common.battery"); + } + + if (isBluetoothDevice(device) && device.name) { + return device.name; + } + + if (device.model) { + return device.model; + } + + return ""; + } + + function getIcon(percent, charging, pluggedIn, isReady) { + if (!isReady) { + return "battery-exclamation"; + } + if (charging) { + return "battery-charging"; + } + if (pluggedIn) { + return "battery-charging-2"; + } + + const icons = [ + { + threshold: 86, + icon: "battery-4" + }, + { + threshold: 56, + icon: "battery-3" + }, + { + threshold: 31, + icon: "battery-2" + }, + { + threshold: 11, + icon: "battery-1" + }, + { + threshold: 0, + icon: "battery" + } + ]; + + const match = icons.find(tier => percent >= tier.threshold); + return match ? match.icon : "battery-off"; // New fallback icon clearly represent if nothing is true here. + } + + function getRateText(device) { + if (!device || device.changeRate === undefined) { + return ""; + } + const rate = Math.abs(device.changeRate); + if (device.timeToFull > 0) { + return I18n.tr("battery.charging-rate", { + "rate": rate.toFixed(2) + }); + } else if (device.timeToEmpty > 0) { + return I18n.tr("battery.discharging-rate", { + "rate": rate.toFixed(2) + }); + } + } + + function getTimeRemainingText(device) { + if (!isDeviceReady(device)) { + return I18n.tr("battery.no-battery-detected"); + } + if (isPluggedIn(device)) { + return I18n.tr("battery.plugged-in"); + } else if (device.timeToFull > 0) { + return I18n.tr("battery.time-until-full", { + "time": Time.formatVagueHumanReadableDuration(device.timeToFull) + }); + } else if (device.timeToEmpty > 0) { + return I18n.tr("battery.time-left", { + "time": Time.formatVagueHumanReadableDuration(device.timeToEmpty) + }); + } + return I18n.tr("common.idle"); + } +} diff --git a/config/quickshell/.config/quickshell/Services/BluetoothService.qml b/config/quickshell/.config/quickshell/Services/BluetoothService.qml index 66b98ee..84c39c8 100644 --- a/config/quickshell/.config/quickshell/Services/BluetoothService.qml +++ b/config/quickshell/.config/quickshell/Services/BluetoothService.qml @@ -32,7 +32,7 @@ Singleton { } function init() { - Logger.log("Bluetooth", "Service initialized") + Logger.i("Bluetooth", "Service initialized") } Timer { @@ -46,11 +46,11 @@ Singleton { target: adapter function onEnabledChanged() { if (!adapter) { - Logger.warn("Bluetooth", "onEnabledChanged", "No adapter available") + Logger.w("Bluetooth", "onEnabledChanged", "No adapter available") return } - Logger.debug("Bluetooth", "onEnableChanged", adapter.enabled) + Logger.d("Bluetooth", "onEnableChanged", adapter.enabled) if (adapter.enabled) { discoveryTimer.running = true } @@ -226,7 +226,7 @@ Singleton { return } - Logger.i("Bluetooth", "SetBluetoothEnabled", state) + Logger.d("Bluetooth", "SetBluetoothEnabled", state) adapter.enabled = state } } diff --git a/config/quickshell/.config/quickshell/Services/BrightnessService.qml b/config/quickshell/.config/quickshell/Services/BrightnessService.qml index 6409cf8..401f93d 100644 --- a/config/quickshell/.config/quickshell/Services/BrightnessService.qml +++ b/config/quickshell/.config/quickshell/Services/BrightnessService.qml @@ -11,47 +11,161 @@ Singleton { property list ddcMonitors: [] readonly property list monitors: variants.instances property bool appleDisplayPresent: false + property list availableBacklightDevices: [] + property bool enableDdcSupport: true + property var backlightDeviceMappings: [] + property int brightnessStep: 10 + property bool enforceMinimumBrightness: false function getMonitorForScreen(screen: ShellScreen): var { - return monitors.find(m => m.modelData === screen) + return monitors.find(m => m.modelData === screen); } // Signal emitted when a specific monitor's brightness changes, includes monitor context signal monitorBrightnessChanged(var monitor, real newBrightness) function getAvailableMethods(): list { - var methods = [] - if (monitors.some(m => m.isDdc)) - methods.push("ddcutil") + var methods = []; + if (root.enableDdcSupport && monitors.some(m => m.isDdc)) + methods.push("ddcutil"); if (monitors.some(m => !m.isDdc)) - methods.push("internal") + methods.push("internal"); if (appleDisplayPresent) - methods.push("apple") - return methods + methods.push("apple"); + return methods; } // Global helpers for IPC and shortcuts function increaseBrightness(): void { - monitors.forEach(m => m.increaseBrightness()) + monitors.forEach(m => m.increaseBrightness()); } function decreaseBrightness(): void { - monitors.forEach(m => m.decreaseBrightness()) + monitors.forEach(m => m.decreaseBrightness()); + } + + function setBrightness(value: real): void { + monitors.forEach(m => m.setBrightnessDebounced(value)); } function getDetectedDisplays(): list { - return detectedDisplays + return detectedDisplays; + } + + function normalizeBacklightDevicePath(devicePath): string { + if (devicePath === undefined || devicePath === null) + return ""; + + var normalized = String(devicePath).trim(); + if (normalized === "") + return ""; + + if (normalized.startsWith("/sys/class/backlight/")) + return normalized; + + if (normalized.indexOf("/") === -1) + return "/sys/class/backlight/" + normalized; + + return normalized; + } + + function getBacklightDeviceName(devicePath): string { + var normalized = normalizeBacklightDevicePath(devicePath); + if (normalized === "") + return ""; + + var parts = normalized.split("/"); + while (parts.length > 0 && parts[parts.length - 1] === "") { + parts.pop(); + } + return parts.length > 0 ? parts[parts.length - 1] : ""; + } + + function getMappedBacklightDevice(outputName): string { + var normalizedOutput = String(outputName || "").trim(); + if (normalizedOutput === "") + return ""; + + var mappings = root.backlightDeviceMappings || []; + for (var i = 0; i < mappings.length; i++) { + var mapping = mappings[i]; + if (!mapping || typeof mapping !== "object") + continue; + + if (String(mapping.output || "").trim() === normalizedOutput) + return normalizeBacklightDevicePath(mapping.device || ""); + } + + return ""; + } + + function setMappedBacklightDevice(outputName, devicePath): void { + var normalizedOutput = String(outputName || "").trim(); + if (normalizedOutput === "") + return; + + var normalizedDevicePath = normalizeBacklightDevicePath(devicePath); + var mappings = root.backlightDeviceMappings || []; + var nextMappings = []; + var replaced = false; + + for (var i = 0; i < mappings.length; i++) { + var mapping = mappings[i]; + if (!mapping || typeof mapping !== "object") + continue; + + var mappingOutput = String(mapping.output || "").trim(); + var mappingDevice = normalizeBacklightDevicePath(mapping.device || ""); + if (mappingOutput === "" || mappingDevice === "") + continue; + + if (mappingOutput === normalizedOutput) { + if (!replaced && normalizedDevicePath !== "") { + nextMappings.push({ + "output": normalizedOutput, + "device": normalizedDevicePath + }); + } + replaced = true; + } else { + nextMappings.push({ + "output": mappingOutput, + "device": mappingDevice + }); + } + } + + if (!replaced && normalizedDevicePath !== "") { + nextMappings.push({ + "output": normalizedOutput, + "device": normalizedDevicePath + }); + } + + root.backlightDeviceMappings = nextMappings; + } + + function scanBacklightDevices(): void { + if (!scanBacklightProc.running) + scanBacklightProc.running = true; } reloadableId: "brightness" Component.onCompleted: { - Logger.log("Brightness", "Service started") + Logger.i("Brightness", "Service started"); + scanBacklightDevices(); + if (root.enableDdcSupport) { + ddcProc.running = true; + } } onMonitorsChanged: { - ddcMonitors = [] - ddcProc.running = true + ddcMonitors = []; + scanBacklightDevices(); + if (root.enableDdcSupport) { + ddcProc.running = true; + } } Variants { @@ -69,6 +183,34 @@ Singleton { } } + // Detect available internal backlight devices + Process { + id: scanBacklightProc + command: ["sh", "-c", "for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then echo \"$dev\"; fi; done"] + stdout: StdioCollector { + onStreamFinished: { + var data = text.trim(); + if (data === "") { + root.availableBacklightDevices = []; + return; + } + + var lines = data.split("\n"); + var found = []; + var seen = ({}); + for (var i = 0; i < lines.length; i++) { + var path = root.normalizeBacklightDevicePath(lines[i]); + if (path === "" || seen[path]) + continue; + seen[path] = true; + found.push(path); + } + + root.availableBacklightDevices = found; + } + } + } + // Detect DDC monitors Process { id: ddcProc @@ -76,23 +218,25 @@ Singleton { command: ["ddcutil", "detect", "--sleep-multiplier=0.5"] stdout: StdioCollector { onStreamFinished: { - var displays = text.trim().split("\n\n") + var displays = text.trim().split("\n\n"); ddcProc.ddcMonitors = displays.map(d => { - - var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/) - var modelMatch = d.match(/Model:\s*(.*)/) - var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/) - var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false - var model = modelMatch ? modelMatch[1] : "Unknown" - var bus = busMatch ? busMatch[1] : "Unknown" - Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel) + var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/); + var modelMatch = d.match(/Model:\s*(.*)/); + var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/); + var connectorMatch = d.match(/DRM[_ ]connector:\s*card\d+-(.+)/); + var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false; + var model = modelMatch ? modelMatch[1] : "Unknown"; + var bus = busMatch ? busMatch[1] : "Unknown"; + var connector = connectorMatch ? connectorMatch[1].trim() : ""; + Logger.i("Brightness", "Detected DDC Monitor:", model, "connector:", connector, "bus:", bus, "is DDC:", !ddcModel); return { "model": model, "busNum": bus, + "connector": connector, "isDdc": !ddcModel - } - }) - root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc) + }; + }); + root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc); } } } @@ -101,14 +245,25 @@ Singleton { id: monitor required property ShellScreen modelData - readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model) - readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? "" + readonly property bool isDdc: root.enableDdcSupport && root.ddcMonitors.some(m => m.connector === modelData.name) + readonly property string busNum: root.ddcMonitors.find(m => m.connector === modelData.name)?.busNum ?? "" readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay") readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal") + // Check if brightness control is available for this monitor + readonly property bool brightnessControlAvailable: { + if (isAppleDisplay) + return true; + if (isDdc) + return true; + // For internal displays, check if we have a brightness path + return brightnessPath !== ""; + } + property real brightness property real lastBrightness: 0 property real queuedBrightness: NaN + property bool commandRunning: false // For internal displays - store the backlight device path property string backlightDevice: "" @@ -116,6 +271,7 @@ Singleton { property string maxBrightnessPath: "" property int maxBrightness: 100 property bool ignoreNextChange: false + property bool initInProgress: false // Signal for brightness changes signal brightnessUpdated(real newBrightness) @@ -124,26 +280,63 @@ Singleton { readonly property Process refreshProc: Process { stdout: StdioCollector { onStreamFinished: { - var dataText = text.trim() + var dataText = text.trim(); if (dataText === "") { - return + return; } - var lines = dataText.split("\n") - if (lines.length >= 2) { - var current = parseInt(lines[0].trim()) - var max = parseInt(lines[1].trim()) - if (!isNaN(current) && !isNaN(max) && max > 0) { - var newBrightness = current / max - // Only update if it's actually different (avoid feedback loops) - if (Math.abs(newBrightness - monitor.brightness) > 0.01) { - // Update internal value to match system state - monitor.brightness = newBrightness - monitor.brightnessUpdated(monitor.brightness) - root.monitorBrightnessChanged(monitor, monitor.brightness) + var newBrightness = NaN; + + if (monitor.isAppleDisplay) { + // Apple display format: single integer (0-101) + var val = parseInt(dataText); + if (!isNaN(val)) { + newBrightness = val / 101; + } + } else if (monitor.isDdc) { + // DDC format: "VCP 10 C 100 100" (space-separated) + var parts = dataText.split(" "); + if (parts.length >= 4) { + var current = parseInt(parts[3]); + var max = parseInt(parts[4]); + if (!isNaN(current) && !isNaN(max) && max > 0) { + monitor.maxBrightness = max; + newBrightness = current / max; + } + } + } else { + // Internal display format: two lines (current\nmax) + var lines = dataText.split("\n"); + if (lines.length >= 2) { + var current = parseInt(lines[0].trim()); + var max = parseInt(lines[1].trim()); + if (!isNaN(current) && !isNaN(max) && max > 0) { + newBrightness = current / max; } } } + + // Update if we got a valid brightness value + if (!isNaN(newBrightness) && (Math.abs(newBrightness - monitor.brightness) > 0.001 || monitor.brightness === 0)) { + monitor.brightness = newBrightness; + monitor.brightnessUpdated(monitor.brightness); + root.monitorBrightnessChanged(monitor, monitor.brightness); + Logger.d("Brightness", "Refreshed brightness from system:", monitor.modelData.name, monitor.brightness); + } + } + } + } + + readonly property Process setBrightnessProc: Process { + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + monitor.commandRunning = false; + // If there's a queued brightness change, process it now + if (!isNaN(monitor.queuedBrightness)) { + Qt.callLater(() => { + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; + }); } } } @@ -152,16 +345,16 @@ Singleton { function refreshBrightnessFromSystem() { if (!monitor.isDdc && !monitor.isAppleDisplay) { // For internal displays, query the system directly - refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath] - refreshProc.running = true - } else if (monitor.isDdc) { + refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath]; + refreshProc.running = true; + } else if (monitor.isDdc && monitor.busNum !== "") { // For DDC displays, get the current value - refreshProc.command = ["ddcutil", "-b", monitor.busNum, "getvcp", "10", "--brief"] - refreshProc.running = true + refreshProc.command = ["ddcutil", "-b", monitor.busNum, "--sleep-multiplier=0.05", "getvcp", "10", "--brief"]; + refreshProc.running = true; } else if (monitor.isAppleDisplay) { // For Apple displays, get the current value - refreshProc.command = ["asdbctl", "get"] - refreshProc.running = true + refreshProc.command = ["asdbctl", "get"]; + refreshProc.running = true; } } @@ -175,8 +368,8 @@ Singleton { // When a file change is detected, actively refresh from system // to ensure we get the most up-to-date value Qt.callLater(() => { - monitor.refreshBrightnessFromSystem() - }) + monitor.refreshBrightnessFromSystem(); + }); } } @@ -184,125 +377,166 @@ Singleton { readonly property Process initProc: Process { stdout: StdioCollector { onStreamFinished: { - var dataText = text.trim() + var dataText = text.trim(); if (dataText === "") { - return + return; } + //Logger.i("Brightness", "Raw brightness data for", monitor.modelData.name + ":", dataText) if (monitor.isAppleDisplay) { - var val = parseInt(dataText) + var val = parseInt(dataText); if (!isNaN(val)) { - monitor.brightness = val / 101 - Logger.log("Brightness", "Apple display brightness:", monitor.brightness) + monitor.brightness = val / 101; + Logger.d("Brightness", "Apple display brightness:", monitor.brightness); } } else if (monitor.isDdc) { - var parts = dataText.split(" ") + var parts = dataText.split(" "); if (parts.length >= 4) { - var current = parseInt(parts[3]) - var max = parseInt(parts[4]) + var current = parseInt(parts[3]); + var max = parseInt(parts[4]); if (!isNaN(current) && !isNaN(max) && max > 0) { - monitor.brightness = current / max - Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness) + monitor.maxBrightness = max; + monitor.brightness = current / max; + Logger.d("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness); } } } else { // Internal backlight - parse the response which includes device path - var lines = dataText.split("\n") + var lines = dataText.split("\n"); if (lines.length >= 3) { - monitor.backlightDevice = lines[0] - monitor.brightnessPath = monitor.backlightDevice + "/brightness" - monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness" + monitor.backlightDevice = lines[0]; + monitor.brightnessPath = monitor.backlightDevice + "/brightness"; + monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness"; - var current = parseInt(lines[1]) - var max = parseInt(lines[2]) + var current = parseInt(lines[1]); + var max = parseInt(lines[2]); if (!isNaN(current) && !isNaN(max) && max > 0) { - monitor.maxBrightness = max - monitor.brightness = current / max - Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness) - Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice) + monitor.maxBrightness = max; + monitor.brightness = current / max; + Logger.d("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness); + Logger.d("Brightness", "Using backlight device:", monitor.backlightDevice); } + } else { + monitor.backlightDevice = ""; + monitor.brightnessPath = ""; + monitor.maxBrightnessPath = ""; } } - // Always update - monitor.brightnessUpdated(monitor.brightness) - root.monitorBrightnessChanged(monitor, monitor.brightness) + monitor.initInProgress = false; } } + onExited: (exitCode, exitStatus) => { + monitor.initInProgress = false; + } } - readonly property real stepSize: 5.0 / 100.0 + readonly property real stepSize: root.brightnessStep / 100.0 + readonly property real minBrightnessValue: (root.enforceMinimumBrightness ? 0.01 : 0.0) // Timer for debouncing rapid changes readonly property Timer timer: Timer { - interval: 100 + interval: monitor.isDdc ? 250 : 33 onTriggered: { if (!isNaN(monitor.queuedBrightness)) { - monitor.setBrightness(monitor.queuedBrightness) - monitor.queuedBrightness = NaN + monitor.setBrightness(monitor.queuedBrightness); + monitor.queuedBrightness = NaN; } } } function setBrightnessDebounced(value: real): void { - monitor.queuedBrightness = value - timer.start() + monitor.queuedBrightness = value; + timer.start(); } function increaseBrightness(): void { - const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness - setBrightnessDebounced(value + stepSize) + const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness; + // Enforce minimum brightness if enabled + if (root.enforceMinimumBrightness && value < minBrightnessValue) { + setBrightnessDebounced(Math.max(stepSize, minBrightnessValue)); + } else { + // Normal brightness increase + setBrightnessDebounced(value + stepSize); + } } function decreaseBrightness(): void { - const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness - setBrightnessDebounced(value - stepSize) + const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness; + setBrightnessDebounced(value - stepSize); } function setBrightness(value: real): void { - value = Math.max(0, Math.min(1, value)) - var rounded = Math.round(value * 100) + value = Math.max(minBrightnessValue, Math.min(1, value)); + var rounded = Math.round(value * 100); + + // Always update internal value and trigger UI feedback immediately + monitor.brightness = value; + monitor.brightnessUpdated(value); + root.monitorBrightnessChanged(monitor, monitor.brightness); if (timer.running) { - monitor.queuedBrightness = value - return + monitor.queuedBrightness = value; + return; } - // Update internal value and trigger UI feedback - monitor.brightness = value - monitor.brightnessUpdated(value) - root.monitorBrightnessChanged(monitor, monitor.brightness) + // If a command is already running, queue this value + if (monitor.commandRunning) { + monitor.queuedBrightness = value; + return; + } + // Execute the brightness change command if (isAppleDisplay) { - monitor.ignoreNextChange = true - Quickshell.execDetached(["asdbctl", "set", rounded]) - } else if (isDdc) { - monitor.ignoreNextChange = true - Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]) - } else { - monitor.ignoreNextChange = true - Quickshell.execDetached(["sh", "-c", "brightnessctl -d $BRIGHTNESSCTL_DEVICE set "+ rounded + "%"]) - } - - if (isDdc) { - timer.restart() + monitor.commandRunning = true; + monitor.ignoreNextChange = true; + setBrightnessProc.command = ["asdbctl", "set", rounded]; + setBrightnessProc.running = true; + } else if (isDdc && busNum !== "") { + monitor.commandRunning = true; + monitor.ignoreNextChange = true; + var ddcValue = Math.round(value * monitor.maxBrightness); + var ddcBus = busNum; + Qt.callLater(() => { + setBrightnessProc.command = ["ddcutil", "-b", ddcBus, "--noverify", "--async", "--sleep-multiplier=0.05", "setvcp", "10", ddcValue]; + setBrightnessProc.running = true; + }); + } else if (!isDdc) { + monitor.commandRunning = true; + monitor.ignoreNextChange = true; + var backlightDeviceName = root.getBacklightDeviceName(monitor.backlightDevice); + if (backlightDeviceName !== "") { + setBrightnessProc.command = ["brightnessctl", "-d", backlightDeviceName, "s", rounded + "%"]; + } else { + setBrightnessProc.command = ["brightnessctl", "s", rounded + "%"]; + } + setBrightnessProc.running = true; } } function initBrightness(): void { + monitor.initInProgress = true; if (isAppleDisplay) { - initProc.command = ["asdbctl", "get"] - } else if (isDdc) { - initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] + initProc.command = ["asdbctl", "get"]; + initProc.running = true; + } else if (isDdc && busNum !== "") { + initProc.command = ["ddcutil", "-b", busNum, "--sleep-multiplier=0.05", "getvcp", "10", "--brief"]; + initProc.running = true; + } else if (!isDdc) { + // Internal backlight: first try explicit output mapping, then fall back to first available. + var preferredDevicePath = root.getMappedBacklightDevice(modelData.name); + var probeScript = ["preferred=\"$1\"", "if [ -n \"$preferred\" ] && [ ! -d \"$preferred\" ]; then preferred=\"/sys/class/backlight/$preferred\"; fi", "selected=\"\"", + "if [ -n \"$preferred\" ] && [ -f \"$preferred/brightness\" ] && [ -f \"$preferred/max_brightness\" ]; then selected=\"$preferred\"; else for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then selected=\"$dev\"; break; fi; done; fi", + "if [ -n \"$selected\" ]; then echo \"$selected\"; cat \"$selected/brightness\"; cat \"$selected/max_brightness\"; fi"].join("; "); + initProc.command = ["sh", "-c", probeScript, "sh", preferredDevicePath]; + initProc.running = true; } else { - // Internal backlight - find the first available backlight device and get its info - // This now returns: device_path, current_brightness, max_brightness (on separate lines) - initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do " + " if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then " + " echo \"$dev\"; " + " cat \"$dev/brightness\"; " + " cat \"$dev/max_brightness\"; " + " break; " + " fi; " + "done"] + monitor.initInProgress = false; } - initProc.running = true } onBusNumChanged: initBrightness() + onIsDdcChanged: initBrightness() Component.onCompleted: initBrightness() } } diff --git a/config/quickshell/.config/quickshell/Services/CacheService.qml b/config/quickshell/.config/quickshell/Services/CacheService.qml deleted file mode 100644 index 5a3d0f0..0000000 --- a/config/quickshell/.config/quickshell/Services/CacheService.qml +++ /dev/null @@ -1,34 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Utils -pragma Singleton - -Singleton { - id: root - - readonly property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/" - // also create recording directory here - readonly property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/" - readonly property var cacheFiles: Object.freeze(["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt", "Network.json"]) - property bool loaded: false - readonly property string locationCacheFile: cacheDir + "Location.json" - readonly property string ipCacheFile: cacheDir + "Ip.json" - readonly property string notificationsCacheFile: cacheDir + "Notifications.json" - readonly property string lyricsOffsetCacheFile: cacheDir + "LyricsOffset.txt" - readonly property string networkCacheFile: cacheDir + "Network.json" - - Process { - id: process - - running: true - command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`] - onExited: (code, status) => { - if (code === 0) - loaded = true; - else - Logger.error("CacheService", `Failed to create cache files: ${command.join(" ")}`); - } - } - -} diff --git a/config/quickshell/.config/quickshell/Services/Caffeine.qml b/config/quickshell/.config/quickshell/Services/Caffeine.qml index 8b676af..c8a0de7 100644 --- a/config/quickshell/.config/quickshell/Services/Caffeine.qml +++ b/config/quickshell/.config/quickshell/Services/Caffeine.qml @@ -1,46 +1,33 @@ import QtQuick import Quickshell import Quickshell.Io -import qs.Services import qs.Utils pragma Singleton Singleton { id: root - property string reason: "Application request" property bool isInhibited: false + property string reason: "User requested" property var activeInhibitors: [] - // Different inhibitor strategies - property string strategy: "systemd" + property var timeout: null // in seconds + // True when the native Wayland IdleInhibitor is handling inhibition + // (set by the IdleInhibitor element in MainScreen via the nativeInhibitor property) + property bool nativeInhibitorAvailable: false - // Auto-detect the best strategy - function detectStrategy() { - if (strategy === "auto") { - // Check if systemd-inhibit is available - try { - var systemdResult = Quickshell.execDetached(["which", "systemd-inhibit"]); - strategy = "systemd"; - return ; - } catch (e) { - } - try { - var waylandResult = Quickshell.execDetached(["which", "wayhibitor"]); - strategy = "wayland"; - return ; - } catch (e) { - } - strategy = "systemd"; // Fallback to systemd even if not detected - } + function init() { + Logger.i("IdleInhibitor", "Service started"); } // Add an inhibitor function addInhibitor(id, reason = "Application request") { - if (activeInhibitors.includes(id)) + if (activeInhibitors.includes(id)) { + Logger.w("IdleInhibitor", "Inhibitor already active:", id); return false; - + } activeInhibitors.push(id); updateInhibition(reason); + Logger.d("IdleInhibitor", "Added inhibitor:", id); return true; } @@ -48,11 +35,12 @@ Singleton { function removeInhibitor(id) { const index = activeInhibitors.indexOf(id); if (index === -1) { - console.log("Inhibitor not found:", id); + Logger.w("IdleInhibitor", "Inhibitor not found:", id); return false; } activeInhibitors.splice(index, 1); updateInhibition(); + Logger.d("IdleInhibitor", "Removed inhibitor:", id); return true; } @@ -62,7 +50,6 @@ Singleton { if (shouldInhibit === isInhibited) return ; - // No change needed if (shouldInhibit) startInhibition(newReason); else @@ -72,13 +59,12 @@ Singleton { // Start system inhibition function startInhibition(newReason) { reason = newReason; - if (strategy === "systemd") - startSystemdInhibition(); - else if (strategy === "wayland") - startWaylandInhibition(); + if (nativeInhibitorAvailable) + Logger.d("IdleInhibitor", "Native inhibitor active"); else - return ; + startSubprocessInhibition(); isInhibited = true; + Logger.i("IdleInhibitor", "Started inhibition:", reason); } // Stop system inhibition @@ -86,57 +72,127 @@ Singleton { if (!isInhibited) return ; - // SIGTERM - if (inhibitorProcess.running) + if (!nativeInhibitorAvailable && inhibitorProcess.running) inhibitorProcess.signal(15); + // SIGTERM isInhibited = false; + Logger.i("IdleInhibitor", "Stopped inhibition"); } - // Systemd inhibition using systemd-inhibit - function startSystemdInhibition() { + // Subprocess fallback using systemd-inhibit + function startSubprocessInhibition() { inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"]; inhibitorProcess.running = true; } - // Wayland inhibition using wayhibitor or similar - function startWaylandInhibition() { - inhibitorProcess.command = ["wayhibitor"]; - inhibitorProcess.running = true; - } - // Manual toggle for user control function manualToggle() { + // clear any existing timeout + timeout = null; if (activeInhibitors.includes("manual")) { - removeInhibitor("manual"); + removeManualInhibitor(); return false; } else { - addInhibitor("manual", "Manually activated by user"); + addManualInhibitor(null); return true; } } - Component.onCompleted: { - detectStrategy(); + function changeTimeout(delta) { + if (timeout == null && delta < 0) + return ; + + if (timeout == null && delta > 0) { + // enable manual inhibitor and set timeout + addManualInhibitor(timeout + delta); + return ; + } + if (timeout + delta <= 0) { + // disable manual inhibitor + removeManualInhibitor(); + return ; + } + if (timeout + delta > 0) { + // change timeout + addManualInhibitor(timeout + delta); + return ; + } } + + function removeManualInhibitor() { + if (timeout !== null) { + timeout = null; + if (inhibitorTimeout.running) + inhibitorTimeout.stop(); + + } + if (activeInhibitors.includes("manual")) { + removeInhibitor("manual"); + Logger.i("IdleInhibitor", "Manual inhibition disabled"); + } + } + + function addManualInhibitor(timeoutSec) { + if (!activeInhibitors.includes("manual")) + addInhibitor("manual", "Manually activated by user"); + + if (timeoutSec === null && timeout === null) { + Logger.i("IdleInhibitor", "Manual inhibition enabled"); + return ; + } else if (timeoutSec !== null && timeout === null) { + timeout = timeoutSec; + inhibitorTimeout.start(); + Logger.i("IdleInhibitor", "Manual inhibition enabled with timeout:", timeoutSec); + return ; + } else if (timeoutSec !== null && timeout !== null) { + timeout = timeoutSec; + Logger.i("IdleInhibitor", "Manual inhibition timeout changed to:", timeoutSec); + return ; + } else if (timeoutSec === null && timeout !== null) { + timeout = null; + inhibitorTimeout.stop(); + Logger.i("IdleInhibitor", "Manual inhibition timeout cleared"); + return ; + } + } + // Clean up on shutdown Component.onDestruction: { stopInhibition(); } - // Process for maintaining the inhibition + // Process for maintaining the inhibition (subprocess fallback only) Process { id: inhibitorProcess running: false onExited: function(exitCode, exitStatus) { - if (isInhibited) + if (isInhibited) { + Logger.w("IdleInhibitor", "Inhibitor process exited unexpectedly:", exitCode); isInhibited = false; - - Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus); + } } onStarted: function() { - Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId); + Logger.d("IdleInhibitor", "Inhibitor process started successfully"); + } + } + + Timer { + id: inhibitorTimeout + + repeat: true + interval: 1000 // 1 second + onTriggered: function() { + if (timeout == null) { + inhibitorTimeout.stop(); + return ; + } + timeout -= 1; + if (timeout <= 0) { + removeManualInhibitor(); + return ; + } } } diff --git a/config/quickshell/.config/quickshell/Services/HostService.qml b/config/quickshell/.config/quickshell/Services/HostService.qml new file mode 100644 index 0000000..638af29 --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/HostService.qml @@ -0,0 +1,67 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property string username: Quickshell.env("USER") || "user" + property string hostname: "--" + property string uptimeText: "--" + + Process { + id: usernameProcess + + command: ["whoami"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + root.username = this.text.trim(); + usernameProcess.running = false; + } + } + + } + + Process { + id: hostnameProcess + + command: ["cat", "/etc/hostname"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + root.hostname = this.text.trim(); + hostnameProcess.running = false; + } + } + + } + + Timer { + interval: 60000 + repeat: true + running: true + onTriggered: uptimeProcess.running = true + } + + Process { + id: uptimeProcess + + command: ["cat", "/proc/uptime"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + var uptimeSeconds = parseFloat(this.text.trim().split(' ')[0]); + root.uptimeText = Time.formatVagueHumanReadableDuration(uptimeSeconds); + uptimeProcess.running = false; + } + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Services/IPCService.qml b/config/quickshell/.config/quickshell/Services/IPCService.qml index 0759631..4392b22 100644 --- a/config/quickshell/.config/quickshell/Services/IPCService.qml +++ b/config/quickshell/.config/quickshell/Services/IPCService.qml @@ -2,34 +2,103 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Constants +import qs.Services Item { IpcHandler { - function setPrimary(color: color) { - SettingsService.primaryColor = color; + function startOrStopRecording() { + RecordService.startOrStop(); + } + + target: "recording" + } + + IpcHandler { + function clearAll() { + ImageCacheService.clearAll(); + } + + target: "cache" + } + + IpcHandler { + function previewWallpaper(path: string) { + BackgroundService.previewWallpaper(path); + } + + function setWallpaper(path: string) { + BackgroundService.setWallpaper(path); + } + + target: "background" + } + + IpcHandler { + function playPause() { + MediaService.playPause(); + } + + function next() { + MediaService.next(); + } + + function previous() { + MediaService.previous(); + } + + function volumeUp() { + AudioService.increaseVolume(); + } + + function volumeDown() { + AudioService.decreaseVolume(); + } + + function toggleOutputMute() { + AudioService.setOutputMuted(!AudioService.muted); + } + + function toggleInputMute() { + AudioService.setInputMuted(!AudioService.inputMuted); + } + + target: "media" + } + + IpcHandler { + function setColor(name: string, value: color) { + Colors.setColor(name, value); + } + + function unsetColor(name: string) { + Colors.unsetColor(name); + } + + function getColor(name: string) : string { + const hex = String(Colors[name]); + if (hex.startsWith("#") && hex.length === 9) + return "#" + hex.substring(3); + + return hex; } target: "colors" } IpcHandler { - function toggleCalendar() { - calendarPanel.toggle(); + function toggleLeft() { + BarService.toggleLeft(); } - function toggleControlCenter() { - controlCenterPanel.toggle(); + function toggleRight() { + BarService.toggleRight(); } - target: "panels" - } - - IpcHandler { - function toggleBarLyrics() { - SettingsService.showLyricsBar = !SettingsService.showLyricsBar; + function toggleLyrics() { + LyricsService.toggleLyricsBar(); } - target: "lyrics" + target: "bars" } IpcHandler { @@ -40,14 +109,6 @@ Item { target: "idleInhibitor" } - IpcHandler { - function startOrStopRecording() { - RecordService.startOrStop(); - } - - target: "recording" - } - IpcHandler { function toggleSunset() { SunsetService.toggleSunset(); @@ -56,4 +117,16 @@ Item { target: "sunset" } + IpcHandler { + function brightnessUp() { + BrightnessService.getMonitorForScreen(Niri.focusedScreen).increaseBrightness(); + } + + function brightnessDown() { + BrightnessService.getMonitorForScreen(Niri.focusedScreen).decreaseBrightness(); + } + + target: "brightness" + } + } diff --git a/config/quickshell/.config/quickshell/Services/ImageCacheService.qml b/config/quickshell/.config/quickshell/Services/ImageCacheService.qml new file mode 100644 index 0000000..786dddd --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/ImageCacheService.qml @@ -0,0 +1,771 @@ +import "./sha256.js" as Checksum +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Constants +import qs.Utils +pragma Singleton + +Singleton { + id: root + + // ------------------------------------------------- + // Public Properties + // ------------------------------------------------- + property bool imageMagickAvailable: false + property bool initialized: false + // Cache directories + readonly property string baseDir: Paths.cacheDir + "images/" + readonly property string wpThumbDir: baseDir + "wallpapers/thumbnails/" + readonly property string wpLargeDir: baseDir + "wallpapers/large/" + readonly property string wpOverviewDir: baseDir + "wallpapers/overview/" + readonly property string notificationsDir: baseDir + "notifications/" + readonly property string contributorsDir: baseDir + "contributors/" + // Supported image formats - extended list when ImageMagick is available + readonly property var basicImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp"] + readonly property var extendedImageFilters: ["*.jpg", "*.jpeg", "*.png", "*.gif", "*.bmp", "*.webp", "*.avif", "*.heic", "*.heif", "*.tiff", "*.tif", "*.pnm", "*.pgm", "*.ppm", "*.pbm", "*.svg", "*.svgz", "*.ico", "*.icns", "*.jxl", "*.jp2", "*.j2k", "*.exr", "*.hdr", "*.dds", "*.tga"] + readonly property var imageFilters: imageMagickAvailable ? extendedImageFilters : basicImageFilters + // ------------------------------------------------- + // Internal State + // ------------------------------------------------- + property var pendingRequests: ({ + }) + property var fallbackQueue: [] + property bool fallbackProcessing: false + // Process queues to prevent "too many open files" errors + property var utilityProcessQueue: [] + property int runningUtilityProcesses: 0 + readonly property int maxConcurrentUtilityProcesses: 16 + // Separate queue for heavy ImageMagick processing (lower concurrency) + property var imageMagickQueue: [] + property int runningImageMagickProcesses: 0 + readonly property int maxConcurrentImageMagickProcesses: 4 + + // ------------------------------------------------- + // Signals + // ------------------------------------------------- + signal cacheHit(string cacheKey, string cachedPath) + signal cacheMiss(string cacheKey) + signal processingComplete(string cacheKey, string cachedPath) + signal processingFailed(string cacheKey, string error) + + // Check if a file format needs conversion (not natively supported by Qt) + function needsConversion(filePath) { + const ext = "*." + filePath.toLowerCase().split('.').pop(); + return !basicImageFilters.includes(ext); + } + + // ------------------------------------------------- + // Initialization + // ------------------------------------------------- + function init() { + Logger.i("ImageCache", "Service started"); + createDirectories(); + cleanupOldCache(); + checkMagickProcess.running = true; + } + + function createDirectories() { + Quickshell.execDetached(["mkdir", "-p", wpThumbDir]); + Quickshell.execDetached(["mkdir", "-p", wpLargeDir]); + Quickshell.execDetached(["mkdir", "-p", wpOverviewDir]); + Quickshell.execDetached(["mkdir", "-p", notificationsDir]); + Quickshell.execDetached(["mkdir", "-p", contributorsDir]); + } + + function cleanupOldCache() { + const dirs = [wpThumbDir, wpLargeDir, wpOverviewDir, notificationsDir, contributorsDir]; + dirs.forEach(function(dir) { + Quickshell.execDetached(["find", dir, "-type", "f", "-mtime", "+30", "-delete"]); + }); + Logger.d("ImageCache", "Cleanup triggered for files older than 30 days"); + } + + // ------------------------------------------------- + // Public API: Get Thumbnail (384x384) + // ------------------------------------------------- + function getThumbnail(sourcePath, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return ; + } + getMtime(sourcePath, function(mtime) { + const cacheKey = generateThumbnailKey(sourcePath, mtime); + const cachedPath = wpThumbDir + cacheKey + ".png"; + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + if (imageMagickAvailable) + startThumbnailProcessing(sourcePath, cachedPath, cacheKey); + else + queueFallbackProcessing(sourcePath, cachedPath, cacheKey, 384); + }); + }); + } + + // ------------------------------------------------- + // Public API: Get Large Image (scaled to specified dimensions) + // ------------------------------------------------- + function getLarge(sourcePath, width, height, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return ; + } + if (!imageMagickAvailable) { + Logger.d("ImageCache", "ImageMagick not available, using original:", sourcePath); + callback(sourcePath, false); + return ; + } + // Fast dimension check - skip processing if image fits screen AND format is Qt-native + getImageDimensions(sourcePath, function(imgWidth, imgHeight) { + // const fitsScreen = imgWidth > 0 && imgHeight > 0 && imgWidth <= width && imgHeight <= height; + // if (fitsScreen) { + // // Only skip if format is natively supported by Qt + // if (!needsConversion(sourcePath)) { + // Logger.d("ImageCache", `Image ${imgWidth}x${imgHeight} fits screen ${width}x${height}, using original`); + // callback(sourcePath, false); + // return ; + // } + // Logger.d("ImageCache", `Image needs conversion despite fitting screen`); + // } + // Use actual image dimensions if it fits (convert without upscaling), otherwise use screen dimensions + // const targetWidth = fitsScreen ? imgWidth : width; + // const targetHeight = fitsScreen ? imgHeight : height; + const targetWidth = width; + const targetHeight = height; + getMtime(sourcePath, function(mtime) { + const cacheKey = generateLargeKey(sourcePath, width, height, mtime); + const cachedPath = wpLargeDir + cacheKey + ".png"; + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + startLargeProcessing(sourcePath, cachedPath, targetWidth, targetHeight, cacheKey); + }); + }); + }); + } + + // ------------------------------------------------- + // Public API: Get Notification Icon (64x64) + // ------------------------------------------------- + function getNotificationIcon(imageUri, appName, summary, callback) { + if (!imageUri || imageUri === "") { + callback("", false); + return ; + } + // Resolve bare file path for temp check + const filePath = imageUri.startsWith("file://") ? imageUri.substring(7) : imageUri; + // File paths in persistent locations are used directly, not cached + if ((imageUri.startsWith("/") || imageUri.startsWith("file://")) && !isTemporaryPath(filePath)) { + callback(imageUri, false); + return ; + } + const cacheKey = generateNotificationKey(imageUri, appName, summary); + const cachedPath = notificationsDir + cacheKey + ".png"; + // Temporary file paths are copied to cache before the source is cleaned up + if (imageUri.startsWith("/") || imageUri.startsWith("file://")) { + processRequest(cacheKey, cachedPath, imageUri, callback, function() { + copyTempFileToCache(filePath, cachedPath, cacheKey); + }); + return ; + } + processRequest(cacheKey, cachedPath, imageUri, callback, function() { + // Notifications always use Qt fallback (image:// URIs can't be read by ImageMagick) + queueFallbackProcessing(imageUri, cachedPath, cacheKey, 64); + }); + } + + // Check if a path is in a temporary directory that may be cleaned up + function isTemporaryPath(path) { + return path.startsWith("/tmp/"); + } + + // Copy a temporary file to the cache directory + function copyTempFileToCache(sourcePath, destPath, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = destPath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["cp", "--", "${srcEsc}", "${dstEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "CopyTempFile_" + cacheKey, + "processString": processString, + "onComplete": function(exitCode) { + if (exitCode === 0) { + Logger.d("ImageCache", "Temp file cached:", destPath); + notifyCallbacks(cacheKey, destPath, true); + } else { + Logger.w("ImageCache", "Failed to cache temp file:", sourcePath); + notifyCallbacks(cacheKey, "", false); + } + }, + "onError": function() { + Logger.e("ImageCache", "Error caching temp file:", sourcePath); + notifyCallbacks(cacheKey, "", false); + } + }); + } + + // ------------------------------------------------- + // Public API: Get Circular Avatar (256x256) + // ------------------------------------------------- + function getCircularAvatar(url, username, callback) { + if (!url || !username) { + callback("", false); + return ; + } + const cacheKey = username; + const cachedPath = contributorsDir + username + "_circular.png"; + processRequest(cacheKey, cachedPath, url, callback, function() { + if (imageMagickAvailable) { + downloadAndProcessAvatar(url, username, cachedPath, cacheKey); + } else { + // No fallback for circular avatars without ImageMagick + Logger.w("ImageCache", "Circular avatars require ImageMagick"); + notifyCallbacks(cacheKey, "", false); + } + }); + } + + // ------------------------------------------------- + // Public API: Get Blurred Overview (for Niri overview background) + // ------------------------------------------------- + function getBlurredOverview(sourcePath, width, height, tintColor, isDarkMode, callback) { + if (!sourcePath || sourcePath === "") { + callback("", false); + return ; + } + if (!imageMagickAvailable) { + Logger.d("ImageCache", "ImageMagick not available for overview blur, using original:", sourcePath); + callback(sourcePath, false); + return ; + } + getMtime(sourcePath, function(mtime) { + const cacheKey = generateOverviewKey(sourcePath, width, height, tintColor, isDarkMode, mtime); + const cachedPath = wpOverviewDir + cacheKey + ".png"; + processRequest(cacheKey, cachedPath, sourcePath, callback, function() { + startOverviewProcessing(sourcePath, cachedPath, width, height, tintColor, isDarkMode, cacheKey); + }); + }); + } + + // ------------------------------------------------- + // Cache Key Generation + // ------------------------------------------------- + function generateThumbnailKey(sourcePath, mtime) { + const keyString = sourcePath + "@384x384@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + function generateLargeKey(sourcePath, width, height, mtime) { + const keyString = sourcePath + "@" + width + "x" + height + "@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + function generateNotificationKey(imageUri, appName, summary) { + if (imageUri.startsWith("image://qsimage/")) + return Checksum.sha256(appName + "|" + summary); + + return Checksum.sha256(imageUri); + } + + function generateOverviewKey(sourcePath, width, height, tintColor, isDarkMode, mtime) { + const keyString = sourcePath + "@" + width + "x" + height + "@" + tintColor + "@" + (isDarkMode ? "dark" : "light") + "@" + (mtime || "unknown"); + return Checksum.sha256(keyString); + } + + // ------------------------------------------------- + // Request Processing (with coalescing) + // ------------------------------------------------- + function processRequest(cacheKey, cachedPath, sourcePath, callback, processFn) { + // Check if already processing this request + if (pendingRequests[cacheKey]) { + pendingRequests[cacheKey].callbacks.push(callback); + Logger.d("ImageCache", "Coalescing request for:", cacheKey); + return ; + } + // Check cache first + checkFileExists(cachedPath, function(exists) { + if (exists) { + Logger.d("ImageCache", "Cache hit:", cachedPath); + callback(cachedPath, true); + cacheHit(cacheKey, cachedPath); + return ; + } + // Re-check pendingRequests (race condition fix) + if (pendingRequests[cacheKey]) { + pendingRequests[cacheKey].callbacks.push(callback); + return ; + } + // Start new processing + Logger.d("ImageCache", "Cache miss, processing:", sourcePath); + cacheMiss(cacheKey); + pendingRequests[cacheKey] = { + "callbacks": [callback], + "sourcePath": sourcePath + }; + processFn(); + }); + } + + function notifyCallbacks(cacheKey, path, success) { + const request = pendingRequests[cacheKey]; + if (request) { + request.callbacks.forEach(function(cb) { + cb(path, success); + }); + delete pendingRequests[cacheKey]; + } + if (success) + processingComplete(cacheKey, path); + else + processingFailed(cacheKey, "Processing failed"); + } + + // ------------------------------------------------- + // ImageMagick Processing: Thumbnail + // ------------------------------------------------- + function startThumbnailProcessing(sourcePath, outputPath, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output + const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '384x384^' -gravity center -extent 384x384 -unsharp 0x0.5 '${dstEsc}'`; + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Large + // ------------------------------------------------- + function startLargeProcessing(sourcePath, outputPath, width, height, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // Use Lanczos filter for high-quality downscaling, subtle unsharp mask, and PNG for lossless output + const command = `magick '${srcEsc}' -auto-orient -filter Lanczos -resize '${width}x${height}' -gravity center -unsharp 0x0.5 '${dstEsc}'`; + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Blurred Overview + // ------------------------------------------------- + function startOverviewProcessing(sourcePath, outputPath, width, height, tintColor, isDarkMode, cacheKey) { + const srcEsc = sourcePath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // Resize, blur, then tint overlay + const command = `magick '${srcEsc}' -auto-orient -resize '${width}x${height}' -gravity center -blur 0x20 \\( +clone -fill '${tintColor}' -colorize 100 -alpha set -channel A -evaluate set 50% +channel \\) -composite '${dstEsc}'`; + runProcess(command, cacheKey, outputPath, sourcePath); + } + + // ------------------------------------------------- + // ImageMagick Processing: Circular Avatar + // ------------------------------------------------- + function downloadAndProcessAvatar(url, username, outputPath, cacheKey) { + const tempPath = contributorsDir + username + "_temp.png"; + const tempEsc = tempPath.replace(/'/g, "'\\''"); + const urlEsc = url.replace(/'/g, "'\\''"); + // Download first (uses utility queue since curl/wget are lightweight) + const downloadCmd = `curl -L -s -o '${tempEsc}' '${urlEsc}' || wget -q -O '${tempEsc}' '${urlEsc}'`; + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "${downloadCmd.replace(/"/g, '\\"')}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "DownloadProcess_" + cacheKey, + "processString": processString, + "onComplete": function(exitCode) { + if (exitCode !== 0) { + Logger.e("ImageCache", "Failed to download avatar for", username); + notifyCallbacks(cacheKey, "", false); + return ; + } + // Now process with ImageMagick + processCircularAvatar(tempPath, outputPath, cacheKey); + }, + "onError": function() { + notifyCallbacks(cacheKey, "", false); + } + }); + } + + function processCircularAvatar(inputPath, outputPath, cacheKey) { + const srcEsc = inputPath.replace(/'/g, "'\\''"); + const dstEsc = outputPath.replace(/'/g, "'\\''"); + // ImageMagick command for circular crop with alpha + const command = `magick '${srcEsc}' -resize 256x256^ -gravity center -extent 256x256 -alpha set \\( +clone -channel A -evaluate set 0 +channel -fill white -draw 'circle 128,128 128,0' \\) -compose DstIn -composite '${dstEsc}'`; + queueImageMagickProcess({ + "command": command, + "cacheKey": cacheKey, + "onComplete": function(exitCode) { + // Clean up temp file + Quickshell.execDetached(["rm", "-f", inputPath]); + if (exitCode !== 0) { + Logger.e("ImageCache", "Failed to create circular avatar"); + notifyCallbacks(cacheKey, "", false); + } else { + Logger.d("ImageCache", "Circular avatar created:", outputPath); + notifyCallbacks(cacheKey, outputPath, true); + } + }, + "onError": function() { + Quickshell.execDetached(["rm", "-f", inputPath]); + notifyCallbacks(cacheKey, "", false); + } + }); + } + + // Queue an ImageMagick process and run it when a slot is available + function queueImageMagickProcess(request) { + imageMagickQueue.push(request); + processImageMagickQueue(); + } + + // Process queued ImageMagick requests up to the concurrency limit + function processImageMagickQueue() { + while (runningImageMagickProcesses < maxConcurrentImageMagickProcesses && imageMagickQueue.length > 0) { + const request = imageMagickQueue.shift(); + runImageMagickProcess(request); + } + } + + // Actually run an ImageMagick process + function runImageMagickProcess(request) { + runningImageMagickProcesses++; + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", ""] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + try { + const processObj = Qt.createQmlObject(processString, root, "ImageProcess_" + request.cacheKey); + processObj.command = ["sh", "-c", request.command]; + processObj.exited.connect(function(exitCode) { + processObj.destroy(); + runningImageMagickProcesses--; + request.onComplete(exitCode, processObj); + processImageMagickQueue(); + }); + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to create process:", e); + runningImageMagickProcesses--; + request.onError(e); + processImageMagickQueue(); + } + } + + function runProcess(command, cacheKey, outputPath, sourcePath) { + queueImageMagickProcess({ + "command": command, + "cacheKey": cacheKey, + "onComplete": function(exitCode, proc) { + if (exitCode !== 0) { + const stderrText = proc.stderr.text || ""; + Logger.e("ImageCache", "Processing failed:", stderrText); + notifyCallbacks(cacheKey, sourcePath, false); + } else { + Logger.d("ImageCache", "Processing complete:", outputPath); + notifyCallbacks(cacheKey, outputPath, true); + } + }, + "onError": function() { + notifyCallbacks(cacheKey, sourcePath, false); + } + }); + } + + function queueFallbackProcessing(sourcePath, destPath, cacheKey, size) { + fallbackQueue.push({ + "sourcePath": sourcePath, + "destPath": destPath, + "cacheKey": cacheKey, + "size": size + }); + if (!fallbackProcessing) { + fallbackProcessing = true; + const item = fallbackQueue.shift(); + fallbackImage.cacheKey = item.cacheKey; + fallbackImage.destPath = item.destPath; + fallbackImage.targetSize = item.size; + fallbackImage.source = item.sourcePath; + } + } + + // Queue a utility process and run it when a slot is available + function queueUtilityProcess(request) { + utilityProcessQueue.push(request); + processUtilityQueue(); + } + + // Process queued utility requests up to the concurrency limit + function processUtilityQueue() { + while (runningUtilityProcesses < maxConcurrentUtilityProcesses && utilityProcessQueue.length > 0) { + const request = utilityProcessQueue.shift(); + runUtilityProcess(request); + } + } + + // Actually run a utility process + function runUtilityProcess(request) { + runningUtilityProcesses++; + try { + const processObj = Qt.createQmlObject(request.processString, root, request.name); + processObj.exited.connect(function(exitCode) { + processObj.destroy(); + runningUtilityProcesses--; + request.onComplete(exitCode, processObj); + processUtilityQueue(); + }); + processObj.running = true; + } catch (e) { + Logger.e("ImageCache", "Failed to create " + request.name + ":", e); + runningUtilityProcesses--; + request.onError(e); + processUtilityQueue(); + } + } + + function getMtime(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["stat", "-c", "%Y", "${pathEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "MtimeProcess", + "processString": processString, + "onComplete": function(exitCode, proc) { + const mtime = exitCode === 0 ? proc.stdout.text.trim() : ""; + callback(mtime); + }, + "onError": function() { + callback(""); + } + }); + } + + function checkFileExists(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["test", "-f", "${pathEsc}"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "FileExistsProcess", + "processString": processString, + "onComplete": function(exitCode) { + callback(exitCode === 0); + }, + "onError": function() { + callback(false); + } + }); + } + + function getImageDimensions(filePath, callback) { + const pathEsc = filePath.replace(/'/g, "'\\''"); + const processString = ` + import QtQuick + import Quickshell.Io + Process { + command: ["identify", "-ping", "-format", "%w %h", "${pathEsc}[0]"] + stdout: StdioCollector {} + stderr: StdioCollector {} + } + `; + queueUtilityProcess({ + "name": "IdentifyProcess", + "processString": processString, + "onComplete": function(exitCode, proc) { + let width = 0, height = 0; + if (exitCode === 0) { + const parts = proc.stdout.text.trim().split(" "); + if (parts.length >= 2) { + width = parseInt(parts[0], 10) || 0; + height = parseInt(parts[1], 10) || 0; + } + } + callback(width, height); + }, + "onError": function() { + callback(0, 0); + } + }); + } + + // ------------------------------------------------- + // Cache Invalidation + // ------------------------------------------------- + function invalidateThumbnail(sourcePath) { + Logger.i("ImageCache", "Invalidating thumbnail for:", sourcePath); + // Since cache keys include hash, we'd need to track mappings + // For simplicity, clear all thumbnails + clearThumbnails(); + } + + function invalidateLarge(sourcePath) { + Logger.i("ImageCache", "Invalidating large for:", sourcePath); + clearLarge(); + } + + function invalidateNotification(imageId) { + const path = notificationsDir + imageId + ".png"; + Quickshell.execDetached(["rm", "-f", path]); + } + + function invalidateAvatar(username) { + const path = contributorsDir + username + "_circular.png"; + Quickshell.execDetached(["rm", "-f", path]); + } + + // ------------------------------------------------- + // Clear Cache Functions + // ------------------------------------------------- + function clearAll() { + Logger.i("ImageCache", "Clearing all cache"); + clearThumbnails(); + clearLarge(); + clearNotifications(); + clearContributors(); + } + + function clearThumbnails() { + Logger.i("ImageCache", "Clearing thumbnails cache"); + Quickshell.execDetached(["rm", "-rf", wpThumbDir]); + Quickshell.execDetached(["mkdir", "-p", wpThumbDir]); + } + + function clearLarge() { + Logger.i("ImageCache", "Clearing large cache"); + Quickshell.execDetached(["rm", "-rf", wpLargeDir]); + Quickshell.execDetached(["mkdir", "-p", wpLargeDir]); + } + + function clearNotifications() { + Logger.i("ImageCache", "Clearing notifications cache"); + Quickshell.execDetached(["rm", "-rf", notificationsDir]); + Quickshell.execDetached(["mkdir", "-p", notificationsDir]); + } + + function clearContributors() { + Logger.i("ImageCache", "Clearing contributors cache"); + Quickshell.execDetached(["rm", "-rf", contributorsDir]); + Quickshell.execDetached(["mkdir", "-p", contributorsDir]); + } + + // ------------------------------------------------- + // Qt Fallback Renderer + // ------------------------------------------------- + PanelWindow { + id: fallbackRenderer + + implicitWidth: 0 + implicitHeight: 0 + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "noctalia-image-cache-renderer" + color: "transparent" + + Image { + id: fallbackImage + + property string cacheKey: "" + property string destPath: "" + property int targetSize: 256 + + function processNextFallback() { + cacheKey = ""; + destPath = ""; + source = ""; + if (fallbackQueue.length > 0) { + const next = fallbackQueue.shift(); + cacheKey = next.cacheKey; + destPath = next.destPath; + targetSize = next.size; + source = next.sourcePath; + } else { + fallbackProcessing = false; + } + } + + width: targetSize + height: targetSize + visible: true + cache: false + asynchronous: true + fillMode: Image.PreserveAspectCrop + mipmap: true + antialiasing: true + onStatusChanged: { + if (!cacheKey) + return ; + + if (status === Image.Ready) { + grabToImage(function(result) { + if (result.saveToFile(destPath)) { + Logger.d("ImageCache", "Fallback cache created:", destPath); + root.notifyCallbacks(cacheKey, destPath, true); + } else { + Logger.e("ImageCache", "Failed to save fallback cache"); + root.notifyCallbacks(cacheKey, "", false); + } + processNextFallback(); + }); + } else if (status === Image.Error) { + Logger.e("ImageCache", "Fallback image load failed"); + root.notifyCallbacks(cacheKey, "", false); + processNextFallback(); + } + } + } + + mask: Region { + } + + } + + // ------------------------------------------------- + // ImageMagick Detection + // ------------------------------------------------- + Process { + id: checkMagickProcess + + command: ["sh", "-c", "command -v magick"] + running: false + onExited: function(exitCode) { + root.imageMagickAvailable = (exitCode === 0); + root.initialized = true; + if (root.imageMagickAvailable) + Logger.i("ImageCache", "ImageMagick available"); + else + Logger.w("ImageCache", "ImageMagick not found, using Qt fallback"); + } + + stdout: StdioCollector { + } + + stderr: StdioCollector { + } + + } + +} diff --git a/config/quickshell/.config/quickshell/Services/Init.qml b/config/quickshell/.config/quickshell/Services/Init.qml new file mode 100644 index 0000000..cd9d248 --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/Init.qml @@ -0,0 +1,36 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Constants +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property bool loaded: false + + Component.onCompleted: { + let mkdirs = ""; + for (const dir of [Paths.cacheDir, Paths.configDir, Paths.recordingDir]) { + mkdirs += `mkdir -p "${dir}" && `; + } + mkdirs += "true"; + Logger.d("Init", `Creating necessary directories with command: ${mkdirs}`); + process.command = ["sh", "-c", mkdirs]; + process.running = true; + } + + Process { + id: process + + running: false + onExited: (code, status) => { + if (code === 0) + root.loaded = true; + else + Logger.e("Init", `Failed to create necessary directories: ${code} (${status})`); + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/IpService.qml b/config/quickshell/.config/quickshell/Services/IpService.qml index 107dc93..4f489cd 100644 --- a/config/quickshell/.config/quickshell/Services/IpService.qml +++ b/config/quickshell/.config/quickshell/Services/IpService.qml @@ -1,21 +1,23 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Constants import qs.Services import qs.Utils pragma Singleton Singleton { property alias ip: cacheFileAdapter.ip - readonly property string cacheFilePath: CacheService.ipCacheFile - readonly property string aliasFilePath: Qt.resolvedUrl("../Assets/Config/IpAliases.json") + readonly property string cacheFilePath: Paths.cacheDir + "ip.json" + readonly property string aliasFilePath: Paths.configDir + "ip_alias.json" + readonly property string geoinfoTokenFilePath: Paths.configDir + "geo_token.txt" property string countryCode: "N/A" property string alias: "" property real fetchInterval: 120 // in s property real fetchTimeout: 10 // in s readonly property string ipURL: "https://api.uyanide.com/ip" readonly property string geoURL: "https://api.ipinfo.io/lite/" - property string geoURLToken: "" + property string geoURLToken: SettingsService.geoInfoToken function fetchIP() { curl.fetch(ipURL, function(success, data) { @@ -24,20 +26,20 @@ Singleton { const response = JSON.parse(data); if (response && response.ip) { let newIP = response.ip; - Logger.log("IpService", "Fetched IP: " + newIP); + Logger.d("IpService", "Fetched IP: " + newIP); if (newIP !== ip) { ip = newIP; countryCode = "N/A"; fetchGeoInfo(true); // Fetch geo info only if IP has changed } } else { - Logger.error("IpService", "IP response does not contain 'ip' field"); + Logger.e("IpService", "IP response does not contain 'ip' field"); } } catch (e) { - Logger.error("IpService", "Failed to parse IP response: " + e); + Logger.e("IpService", "Failed to parse IP response: " + e); } } else { - Logger.error("IpService", "Failed to fetch IP"); + Logger.e("IpService", "Failed to fetch IP"); } }, true); } @@ -58,17 +60,17 @@ Singleton { const response = JSON.parse(data); if (response && (response.country_code || response.country)) { let newCountryCode = response.country_code || response.country; - Logger.log("IpService", "Fetched country code: " + newCountryCode); + Logger.d("IpService", "Fetched country code: " + newCountryCode); countryCode = newCountryCode; } else { - Logger.error("IpService", "Geo response does not contain 'country_code' field"); + Logger.e("IpService", "Geo response does not contain 'country_code' field"); } cacheFileAdapter.geoInfo = response; } catch (e) { - Logger.error("IpService", "Failed to parse geo response: " + e); + Logger.e("IpService", "Failed to parse geo response: " + e); } } else { - Logger.error("IpService", "Failed to fetch geo info"); + Logger.e("IpService", "Failed to fetch geo info"); } SendNotification.show("New IP", `IP: ${ip}\nCountry: ${countryCode}${alias ? `\nAlias: ${alias}` : ""}`); cacheFile.writeAdapter(); @@ -76,10 +78,9 @@ Singleton { } function refresh() { - fetchTimer.stop(); ip = "N/A"; - fetchIP(); - fetchTimer.start(); + countryCode = "N/A"; + fetchIPDebouncer.restart(); } function updateAlias() { @@ -88,13 +89,9 @@ Singleton { return ; } alias = ""; - for (let i = 0; i < aliasFileAdapter.aliases.length; i++) { - let entry = aliasFileAdapter.aliases[i]; - if (entry.ip === ip) { - alias = entry.alias; - Logger.log("IpService", "Found alias for IP " + ip + ": " + alias); - break; - } + if (SettingsService.ipAliases[ip]) { + alias = SettingsService.ipAliases[ip]; + Logger.d("IpService", "Found alias for IP " + ip + ": " + alias); } } @@ -118,14 +115,14 @@ Singleton { stdout: SplitParser { splitMarker: "\n" onRead: { - ipMonitorDebounce.restart(); + fetchIPDebouncer.restart(); } } } Timer { - id: ipMonitorDebounce + id: fetchIPDebouncer interval: 1000 repeat: false @@ -142,26 +139,7 @@ Singleton { repeat: true running: true onTriggered: { - fetchTimer.stop(); - fetchIP(); - fetchTimer.start(); - } - } - - FileView { - id: tokenFile - - path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt") - onLoaded: { - geoURLToken = tokenFile.text(); - if (!geoURLToken) - Logger.warn("IpService", "No token found for geoIP service, assuming none is required"); - - if (geoURLToken[geoURLToken.length - 1] === "\n") - geoURLToken = geoURLToken.slice(0, -1); - - fetchIP(); - fetchTimer.start(); + fetchIPDebouncer.restart(); } } @@ -171,10 +149,10 @@ Singleton { path: cacheFilePath watchChanges: false onLoaded: { - Logger.log("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip); + Logger.d("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip); if (cacheFileAdapter.geoInfo) { countryCode = cacheFileAdapter.geoInfo.country_code || cacheFileAdapter.country || "N/A"; - Logger.log("IpService", "Loaded country code from cache file: " + countryCode); + Logger.d("IpService", "Loaded country code from cache file: " + countryCode); } } @@ -187,21 +165,4 @@ Singleton { } - FileView { - id: aliasFile - - path: aliasFilePath - watchChanges: true - onLoaded: { - Logger.log("IpService", "Loaded IP aliases from file, total aliases: " + aliasFileAdapter.aliases.length); - } - - JsonAdapter { - id: aliasFileAdapter - - property var aliases: [] - } - - } - } diff --git a/config/quickshell/.config/quickshell/Services/LocationService.qml b/config/quickshell/.config/quickshell/Services/LocationService.qml index a3a2c45..a917ab1 100644 --- a/config/quickshell/.config/quickshell/Services/LocationService.qml +++ b/config/quickshell/.config/quickshell/Services/LocationService.qml @@ -2,27 +2,23 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Constants -import qs.Services import qs.Utils pragma Singleton -// Weather logic and caching with stable UI properties +// Location and weather service with decoupled geocoding and weather fetching. Singleton { - //console.log(JSON.stringify(weatherData)) - id: root - property string locationName: SettingsService.location - property string locationFile: CacheService.locationCacheFile + property string locationFile: Paths.cacheDir + "location.json" property int weatherUpdateFrequency: 30 * 60 // 30 minutes expressed in seconds property bool isFetchingWeather: false - readonly property alias data: adapter // Used to access via LocationService.data.xxx from outside, best to use "adapter" inside the service. - // Stable UI properties - only updated when location is fully resolved + readonly property alias data: adapter + // Stable UI properties - only updated when location is successfully geocoded property bool coordinatesReady: false property string stableLatitude: "" property string stableLongitude: "" property string stableName: "" - // Helper property for UI components (outside JsonAdapter to avoid binding loops) + // Formatted coordinates for UI display readonly property string displayCoordinates: { if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "") return ""; @@ -32,127 +28,153 @@ Singleton { return `${lat}, ${lon}`; } - // -------------------------------- function init() { - // does nothing but ensure the singleton is created - // do not remove - Logger.log("Location", "Service started"); + Logger.i("Location", "Service started"); } - // -------------------------------- function resetWeather() { - Logger.log("Location", "Resetting weather data"); - // Mark as changing to prevent UI updates + Logger.i("Location", "Resetting location and weather data"); root.coordinatesReady = false; - // Reset stable properties root.stableLatitude = ""; root.stableLongitude = ""; root.stableName = ""; - // Reset core data adapter.latitude = ""; adapter.longitude = ""; adapter.name = ""; adapter.weatherLastFetch = 0; adapter.weather = null; - // Try to fetch immediately - updateWeather(); + update(); } - // -------------------------------- - function updateWeather() { + // Main update function - geocodes location if needed, then fetches weather if enabled + function update() { + updateLocation(); + updateWeatherData(); + } + + // Runs independently of weather toggle + function updateLocation() { + const locationChanged = adapter.name !== SettingsService.location; + const needsGeocoding = (adapter.latitude === "") || (adapter.longitude === "") || locationChanged; + if (!needsGeocoding) + return ; + if (isFetchingWeather) { - Logger.warn("Location", "Weather is still fetching"); + Logger.w("Location", "Location update already in progress"); return ; } - if ((adapter.weatherLastFetch === "") || (adapter.weather === null) || (adapter.latitude === "") || (adapter.longitude === "") || (adapter.name !== root.locationName) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency)) - getFreshWeather(); - - } - - // -------------------------------- - function getFreshWeather() { isFetchingWeather = true; - // Check if location name has changed - const locationChanged = data.name !== root.locationName; if (locationChanged) { root.coordinatesReady = false; - Logger.log("Location", "Location changed from", adapter.name, "to", root.locationName); + Logger.d("Location", "Location changed from", adapter.name, "to", SettingsService.location); } - if ((adapter.latitude === "") || (adapter.longitude === "") || locationChanged) - _geocodeLocation(root.locationName, function(latitude, longitude, name, country) { - Logger.log("Location", "Geocoded", root.locationName, "to:", latitude, "/", longitude); - // Save location name - adapter.name = root.locationName; - // Save GPS coordinates + geocodeLocation(SettingsService.location, function(latitude, longitude, name, country) { + Logger.d("Location", "Geocoded", SettingsService.location, "to:", latitude, "/", longitude); + adapter.name = SettingsService.location; adapter.latitude = latitude.toString(); adapter.longitude = longitude.toString(); + root.stableLatitude = adapter.latitude; + root.stableLongitude = adapter.longitude; root.stableName = `${name}, ${country}`; - _fetchWeather(latitude, longitude, errorCallback); + root.coordinatesReady = true; + isFetchingWeather = false; + Logger.i("Location", "Coordinates ready"); + if (locationChanged) { + adapter.weatherLastFetch = 0; + updateWeatherData(); + } }, errorCallback); - else - _fetchWeather(adapter.latitude, adapter.longitude, errorCallback); } - // -------------------------------- - function _geocodeLocation(locationName, callback, errorCallback) { - Logger.log("Location", "Geocoding location name"); - var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent(locationName) + "&language=en&format=json"; - curl.fetch(geoUrl, function(success, data) { - if (success) { - try { - var geoData = JSON.parse(data); - if (geoData.lat != null) - callback(geoData.lat, geoData.lng, geoData.name, geoData.country); - else - errorCallback("Location", "could not resolve location name"); - } catch (e) { - errorCallback("Location", "Failed to parse geocoding data: " + e); - } - } else { - errorCallback("Location", "Geocoding error"); - } - }); + // Fetch weather data if enabled and coordinates are available + function updateWeatherData() { + if (isFetchingWeather) { + Logger.w("Location", "Weather is still fetching"); + return ; + } + if (adapter.latitude === "" || adapter.longitude === "") { + Logger.w("Location", "Cannot fetch weather without coordinates"); + return ; + } + const needsWeatherUpdate = (adapter.weatherLastFetch === "") || (adapter.weather === null) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency); + if (needsWeatherUpdate) { + isFetchingWeather = true; + fetchWeatherData(adapter.latitude, adapter.longitude, errorCallback); + } } - // -------------------------------- - function _fetchWeather(latitude, longitude, errorCallback) { - Logger.log("Location", "Fetching weather from api.open-meteo.com"); - var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto"; - curl.fetch(url, function(success, fetchedData) { - if (success) { - try { - var weatherData = JSON.parse(fetchedData); - // Save core data - data.weather = weatherData; - data.weatherLastFetch = Time.timestamp; - // Update stable display values only when complete and successful - root.stableLatitude = data.latitude = weatherData.latitude.toString(); - root.stableLongitude = data.longitude = weatherData.longitude.toString(); - root.coordinatesReady = true; - isFetchingWeather = false; - Logger.log("Location", "Cached weather to disk - stable coordinates updated"); - } catch (e) { - errorCallback("Location", "Failed to parse weather data: " + e); + // Query geocoding API to convert location name to coordinates + function geocodeLocation(locationName, callback, errorCallback) { + Logger.d("Location", "Geocoding location name"); + var geoUrl = "https://api.noctalia.dev/geocode?city=" + encodeURIComponent(locationName); + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) { + try { + var geoData = JSON.parse(xhr.responseText); + if (geoData.lat != null) + callback(geoData.lat, geoData.lng, geoData.name, geoData.country); + else + errorCallback("Location", "could not resolve location name"); + } catch (e) { + errorCallback("Location", "Failed to parse geocoding data: " + e); + } + } else { + errorCallback("Location", "Geocoding error: " + xhr.status); } - } else { - errorCallback("Location", "Weather fetch error"); } - }); + }; + xhr.open("GET", geoUrl); + xhr.send(); + } + + // Fetch weather data from Open-Meteo API + function fetchWeatherData(latitude, longitude, errorCallback) { + Logger.d("Location", "Fetching weather from api.open-meteo.com"); + var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure,is_day&daily=temperature_2m_max,temperature_2m_min,weathercode,sunset,sunrise&timezone=auto"; + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState === XMLHttpRequest.DONE) { + //console.log(JSON.stringify(weatherData)) + + if (xhr.status === 200) { + try { + var weatherData = JSON.parse(xhr.responseText); + // Save core data + data.weather = weatherData; + data.weatherLastFetch = Time.timestamp; + // Update stable display values only when complete and successful + root.stableLatitude = data.latitude = weatherData.latitude.toString(); + root.stableLongitude = data.longitude = weatherData.longitude.toString(); + root.coordinatesReady = true; + isFetchingWeather = false; + Logger.d("Location", "Cached weather to disk - stable coordinates updated"); + } catch (e) { + errorCallback("Location", "Failed to parse weather data"); + } + } else { + errorCallback("Location", "Weather fetch error: " + xhr.status); + } + } + }; + xhr.open("GET", url); + xhr.send(); } // -------------------------------- function errorCallback(module, message) { - Logger.error(module, message); + Logger.e(module, message); isFetchingWeather = false; } // -------------------------------- - function weatherSymbolFromCode(code) { + function weatherSymbolFromCode(code, isDay) { if (code === 0) - return "weather-sun"; + return isDay ? "weather-sun" : "weather-moon"; if (code === 1 || code === 2) - return "weather-cloud-sun"; + return isDay ? "weather-cloud-sun" : "weather-moon-stars"; if (code === 3) return "weather-cloud"; @@ -163,6 +185,9 @@ Singleton { if (code >= 51 && code <= 67) return "weather-cloud-rain"; + if (code >= 80 && code <= 82) + return "weather-cloud-rain"; + if (code >= 71 && code <= 77) return "weather-cloud-snow"; @@ -178,47 +203,6 @@ Singleton { return "weather-cloud"; } - function weatherColorFromCode(code) { - // Clear sky - bright yellow - if (code === 0) - return Colors.yellow; - - // Mainly clear/Partly cloudy - soft peach/rosewater tones - if (code === 1 || code === 2) - return Colors.peach; - - // Overcast - neutral sky blue - if (code === 3) - return Colors.sky; - - // Fog - soft lavender/muted tone - if (code >= 45 && code <= 48) - return Colors.lavender; - - // Drizzle - light blue/sapphire - if (code >= 51 && code <= 67) - return Colors.sapphire; - - // Snow - cool teal - if (code >= 71 && code <= 77) - return Colors.teal; - - // Rain showers - deeper blue - if (code >= 80 && code <= 82) - return Colors.blue; - - // Snow showers - teal - if (code >= 85 && code <= 86) - return Colors.teal; - - // Thunderstorm - dramatic mauve/pink - if (code >= 95 && code <= 99) - return Colors.mauve; - - // Default - sky blue - return Colors.sky; - } - // -------------------------------- function weatherDescriptionFromCode(code) { if (code === 0) @@ -263,25 +247,23 @@ Singleton { printErrors: false onAdapterUpdated: saveTimer.start() onLoaded: { - Logger.log("Location", "Loaded cached data"); - // Initialize stable properties on load + Logger.d("Location", "Loaded cached data"); if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) { root.stableLatitude = adapter.latitude; root.stableLongitude = adapter.longitude; root.stableName = adapter.name; root.coordinatesReady = true; - Logger.log("Location", "Coordinates ready"); + Logger.i("Location", "Coordinates ready"); } - updateWeather(); + update(); } onLoadFailed: function(error) { - updateWeather(); + update(); } JsonAdapter { id: adapter - // Core data properties property string latitude: "" property string longitude: "" property string name: "" @@ -291,7 +273,7 @@ Singleton { } - // Every 20s check if we need to fetch new weather + // Update timer runs when weather is enabled or location-based scheduling is active Timer { id: updateTimer @@ -299,7 +281,7 @@ Singleton { running: true repeat: true onTriggered: { - updateWeather(); + update(); } } @@ -311,8 +293,4 @@ Singleton { onTriggered: locationFileView.writeAdapter() } - NetworkFetch { - id: curl - } - } diff --git a/config/quickshell/.config/quickshell/Services/LyricsService.qml b/config/quickshell/.config/quickshell/Services/LyricsService.qml index e41f73c..c732355 100644 --- a/config/quickshell/.config/quickshell/Services/LyricsService.qml +++ b/config/quickshell/.config/quickshell/Services/LyricsService.qml @@ -1,15 +1,18 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Constants import qs.Services import qs.Utils pragma Singleton Singleton { + id: root + property int linesCount: 3 property int linesAhead: linesCount / 2 readonly property int currentIndex: linesCount - linesAhead - 1 - readonly property string offsetFile: CacheService.lyricsOffsetCacheFile + readonly property string offsetFile: Paths.cacheDir + "/spotify-lyrics-offset.txt" property int offset: 0 // in ms readonly property int offsetStep: 500 // in ms property int referenceCount: 0 @@ -18,12 +21,19 @@ Singleton { // line 2 <- current line // line 3 property var lyrics: Array(linesCount).fill(" ") + property bool showLyricsBar: ShellState.lyricsState.showLyricsBar || false + + function toggleLyricsBar() { + ShellState.lyricsState = { + "showLyricsBar": !root.showLyricsBar + }; + } function startSyncing() { referenceCount++; - Logger.log("LyricsService", "Reference count:", referenceCount); + Logger.d("LyricsService", "Reference count:", referenceCount); if (referenceCount === 1) { - Logger.log("LyricsService", "Starting lyrics syncing"); + Logger.d("LyricsService", "Starting lyrics syncing"); // fill lyrics with empty lines lyrics = Array(linesCount).fill(" "); listenProcess.exec(["sh", "-c", `pkill -x spotify-lyrics -u $USER; spotify-lyrics listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile}`]); @@ -32,9 +42,9 @@ Singleton { function stopSyncing() { referenceCount--; - Logger.log("LyricsService", "Reference count:", referenceCount); + Logger.d("LyricsService", "Reference count:", referenceCount); if (referenceCount <= 0) { - Logger.log("LyricsService", "Stopping lyrics syncing"); + Logger.d("LyricsService", "Stopping lyrics syncing"); // kinda ugly but works, meanwhile: // listenProcess.signal(9) // listenProcess.signal(15) @@ -51,14 +61,17 @@ Singleton { function increaseOffset() { offset += offsetStep; + saveState(); } function decreaseOffset() { offset -= offsetStep; + saveState(); } function resetOffset() { offset = 0; + saveState(); } function clearCache() { @@ -72,7 +85,7 @@ Singleton { } onOffsetChanged: { - if (SettingsService.showLyricsBar) + if (root.showLyricsBar) SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`); writeOffset(); @@ -116,7 +129,7 @@ Singleton { const val = parseInt(fileContents); if (!isNaN(val)) { offset = val; - Logger.log("LyricsService", "Loaded offset:", offset); + Logger.d("LyricsService", "Loaded offset:", offset); } else { offset = 0; writeOffset(); @@ -126,14 +139,14 @@ Singleton { writeOffset(); } } catch (e) { - Logger.error("LyricsService", "Error reading offset file:", e); + Logger.e("LyricsService", "Error reading offset file:", e); } } onLoadFailed: { - Logger.error("LyricsService", "Error loading offset file:", errorString); + Logger.e("LyricsService", "Error loading offset file."); } onSaveFailed: { - Logger.error("LyricsService", "Error saving offset file:", errorString); + Logger.e("LyricsService", "Error saving offset file."); } } diff --git a/config/quickshell/.config/quickshell/Services/MediaService.qml b/config/quickshell/.config/quickshell/Services/MediaService.qml new file mode 100644 index 0000000..10dff2b --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/MediaService.qml @@ -0,0 +1,345 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Mpris +import qs.Utils +pragma Singleton + +Singleton { + //Logger.i("Media", "No active player found") + + id: root + + property var currentPlayer: null + property string playerIdentity: currentPlayer ? (currentPlayer.identity || "") : "" + property real currentPosition: 0 + property bool isSeeking: false + property int selectedPlayerIndex: 0 + property bool isPlaying: currentPlayer ? (currentPlayer.playbackState === MprisPlaybackState.Playing || currentPlayer.isPlaying) : false + property string trackTitle: currentPlayer ? (currentPlayer.trackTitle !== undefined ? currentPlayer.trackTitle.replace(/(\r\n|\n|\r)/g, "") : "") : "" + property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "") : "" + property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "") : "" + property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : "" + property real trackLength: currentPlayer ? ((currentPlayer.length < infiniteTrackLength) ? currentPlayer.length : 0) : 0 + property bool canPlay: currentPlayer ? currentPlayer.canPlay : false + property bool canPause: currentPlayer ? currentPlayer.canPause : false + property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false + property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false + property bool canSeek: currentPlayer ? currentPlayer.canSeek : false + property string positionString: formatTime(currentPosition) + property string lengthString: formatTime(trackLength) + property real infiniteTrackLength: 9.22337e+11 + property bool autoSwitchingPaused: false + + function formatTime(seconds) { + if (isNaN(seconds) || seconds < 0) + return "0:00"; + + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + var s = Math.floor(seconds % 60); + var pad = function pad(n) { + return (n < 10) ? ("0" + n) : n; + }; + if (h > 0) + return h + ":" + pad(m) + ":" + pad(s); + else + return m + ":" + pad(s); + } + + function getAvailablePlayers() { + if (!Mpris.players || !Mpris.players.values) + return []; + + let allPlayers = Mpris.players.values; + let finalPlayers = []; + const genericBrowsers = ["firefox", "chromium", "chrome"]; + const blacklist = []; + // Separate players into specific and generic lists + let specificPlayers = []; + let genericPlayers = []; + for (var i = 0; i < allPlayers.length; i++) { + const identity = String(allPlayers[i].identity || "").toLowerCase(); + const name = String(allPlayers[i].name || "").toLowerCase(); + const match = blacklist.find((b) => { + const s = String(b || "").toLowerCase(); + return s && (identity.includes(s) || name.includes(s)); + }); + if (match) + continue; + + if (genericBrowsers.some((b) => { + return identity.includes(b); + })) + genericPlayers.push(allPlayers[i]); + else + specificPlayers.push(allPlayers[i]); + } + let matchedGenericIndices = { + }; + // For each specific player, try to find and pair it with a generic partner + for (var i = 0; i < specificPlayers.length; i++) { + let specificPlayer = specificPlayers[i]; + let title1 = String(specificPlayer.trackTitle || "").trim(); + let wasMatched = false; + if (title1) { + for (var j = 0; j < genericPlayers.length; j++) { + if (matchedGenericIndices[j]) + continue; + + let genericPlayer = genericPlayers[j]; + let title2 = String(genericPlayer.trackTitle || "").trim(); + if (title2 && (title1.includes(title2) || title2.includes(title1))) { + let dataPlayer = genericPlayer; + let identityPlayer = specificPlayer; + let scoreSpecific = (specificPlayer.trackArtUrl ? 1 : 0); + let scoreGeneric = (genericPlayer.trackArtUrl ? 1 : 0); + if (scoreSpecific > scoreGeneric) + dataPlayer = specificPlayer; + + let virtualPlayer = { + "identity": identityPlayer.identity, + "desktopEntry": identityPlayer.desktopEntry, + "trackTitle": dataPlayer.trackTitle, + "trackArtist": dataPlayer.trackArtist, + "trackAlbum": dataPlayer.trackAlbum, + "trackArtUrl": dataPlayer.trackArtUrl, + "length": dataPlayer.length || 0, + "position": dataPlayer.position || 0, + "playbackState": dataPlayer.playbackState, + "isPlaying": dataPlayer.isPlaying || false, + "canPlay": dataPlayer.canPlay || false, + "canPause": dataPlayer.canPause || false, + "canGoNext": dataPlayer.canGoNext || false, + "canGoPrevious": dataPlayer.canGoPrevious || false, + "canSeek": dataPlayer.canSeek || false, + "canControl": dataPlayer.canControl || false, + "_stateSource": dataPlayer, + "_controlTarget": identityPlayer + }; + finalPlayers.push(virtualPlayer); + matchedGenericIndices[j] = true; + wasMatched = true; + break; + } + } + } + if (!wasMatched) + finalPlayers.push(specificPlayer); + + } + // Add any generic players that were not matched + for (var i = 0; i < genericPlayers.length; i++) { + if (!matchedGenericIndices[i]) + finalPlayers.push(genericPlayers[i]); + + } + // Filter for controllable players + let controllablePlayers = []; + for (var i = 0; i < finalPlayers.length; i++) { + let player = finalPlayers[i]; + if (player && player.canPlay) + controllablePlayers.push(player); + + } + return controllablePlayers; + } + + function findActivePlayer() { + let availablePlayers = getAvailablePlayers(); + if (availablePlayers.length === 0) + return null; + + // Prioritize the actively playing player --- + for (var i = 0; i < availablePlayers.length; i++) { + if (availablePlayers[i] && availablePlayers[i].playbackState === MprisPlaybackState.Playing) { + Logger.d("Media", "Found actively playing player: " + availablePlayers[i].identity); + selectedPlayerIndex = i; + return availablePlayers[i]; + } + } + // fallback if nothing is playing) + if (selectedPlayerIndex < availablePlayers.length) { + return availablePlayers[selectedPlayerIndex]; + } else { + selectedPlayerIndex = 0; + return availablePlayers[0]; + } + } + + function switchToPlayer(index) { + let availablePlayers = getAvailablePlayers(); + if (index >= 0 && index < availablePlayers.length) { + let newPlayer = availablePlayers[index]; + if (newPlayer !== currentPlayer) { + currentPlayer = newPlayer; + selectedPlayerIndex = index; + currentPosition = currentPlayer ? currentPlayer.position : 0; + autoSwitchingPaused = true; + Logger.d("Media", "Manually switched to player " + currentPlayer.identity); + } + } + } + + // Switch to the most recently active player + function updateCurrentPlayer() { + let newPlayer = findActivePlayer(); + if (newPlayer !== currentPlayer) { + currentPlayer = newPlayer; + currentPosition = currentPlayer ? currentPlayer.position : 0; + Logger.d("Media", "Switching player"); + } + } + + function toggleAutoSwitchingPaused() { + if (autoSwitchingPaused) { + autoSwitchingPaused = false; + updateCurrentPlayer(); + } else { + autoSwitchingPaused = true; + } + } + + function playPause() { + if (currentPlayer) { + let stateSource = currentPlayer._stateSource || currentPlayer; + let controlTarget = currentPlayer._controlTarget || currentPlayer; + if (stateSource.playbackState === MprisPlaybackState.Playing) + controlTarget.pause(); + else + controlTarget.play(); + } + } + + function play() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canPlay) + target.play(); + + } + + function stop() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target) + target.stop(); + + } + + function pause() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canPause) + target.pause(); + + } + + function next() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canGoNext) + target.next(); + + } + + function previous() { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canGoPrevious) + target.previous(); + + } + + function seek(position) { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canSeek) { + target.position = position; + currentPosition = position; + } + } + + function seekRelative(offset) { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canSeek && target.length > 0) { + let seekPosition = target.position + offset; + target.position = seekPosition; + currentPosition = seekPosition; + } + } + + // Seek to position based on ratio (0.0 to 1.0) + function seekByRatio(ratio) { + let target = currentPlayer ? (currentPlayer._controlTarget || currentPlayer) : null; + if (target && target.canSeek && target.length > 0) { + let seekPosition = ratio * target.length; + target.position = seekPosition; + currentPosition = seekPosition; + } + } + + Component.onCompleted: { + updateCurrentPlayer(); + } + // Reset position when switching to inactive player + onCurrentPlayerChanged: { + if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) + currentPosition = 0; + + } + + // Update progress bar every second while playing + Timer { + id: positionTimer + + interval: 1000 + running: currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.length > 0 && currentPlayer.playbackState === MprisPlaybackState.Playing + repeat: true + onTriggered: { + if (currentPlayer && !root.isSeeking && currentPlayer.isPlaying && currentPlayer.playbackState === MprisPlaybackState.Playing) + currentPosition = currentPlayer.position; + else + running = false; + } + } + + // Avoid overwriting currentPosition while seeking due to backend position changes + Connections { + function onPositionChanged() { + if (!root.isSeeking && currentPlayer) + currentPosition = currentPlayer.position; + + } + + function onPlaybackStateChanged() { + if (!root.isSeeking && currentPlayer) + currentPosition = currentPlayer.position; + + } + + target: currentPlayer ? (currentPlayer._stateSource || currentPlayer) : null + } + + Timer { + id: playerStateMonitor + + interval: 2000 // Check every 2 seconds + repeat: true + running: true + onTriggered: { + //Logger.d("MediaService", "playerStateMonitor triggered. autoSwitchingPaused: " + root.autoSwitchingPaused) + if (autoSwitchingPaused) + return ; + + // Only update if we don't have a playing player or if current player is paused + if (!currentPlayer || !currentPlayer.isPlaying || currentPlayer.playbackState !== MprisPlaybackState.Playing) + updateCurrentPlayer(); + + } + } + + // Update current player when available players change + Connections { + function onValuesChanged() { + Logger.d("Media", "Players changed"); + updateCurrentPlayer(); + } + + target: Mpris.players + } + +} diff --git a/config/quickshell/.config/quickshell/Services/MusicManager.qml b/config/quickshell/.config/quickshell/Services/MusicManager.qml deleted file mode 100644 index fcbe174..0000000 --- a/config/quickshell/.config/quickshell/Services/MusicManager.qml +++ /dev/null @@ -1,180 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Services.Mpris -import qs.Modules.Misc -import qs.Utils -pragma Singleton - -Singleton { - id: manager - - // Properties - property var currentPlayer: null - property real currentPosition: 0 - property bool isPlaying: currentPlayer ? currentPlayer.isPlaying : false - property int selectedPlayerIndex: -1 - property string trackTitle: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : "" - property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : "" - property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : "" - property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : "" - property real trackLength: currentPlayer ? currentPlayer.length : 0 - property bool canPlay: currentPlayer ? currentPlayer.canPlay : false - property bool canPause: currentPlayer ? currentPlayer.canPause : false - property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false - property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false - property bool canSeek: currentPlayer ? currentPlayer.canSeek : false - property bool hasPlayer: getAvailablePlayers().length > 0 - // Expose cava values - property alias cavaValues: cava.values - - // Returns available MPRIS players - function getAvailablePlayers() { - if (!Mpris.players || !Mpris.players.values) - return []; - - let allPlayers = Mpris.players.values; - let controllablePlayers = []; - for (let i = 0; i < allPlayers.length; i++) { - let player = allPlayers[i]; - if (player && player.canControl) - controllablePlayers.push(player); - - } - return controllablePlayers; - } - - // Returns active player or first available - function findActivePlayer() { - let availablePlayers = getAvailablePlayers(); - if (availablePlayers.length === 0) - return null; - - // Get the first playing player - for (let i = availablePlayers.length - 1; i >= 0; i--) { - if (availablePlayers[i].isPlaying) - return availablePlayers[i]; - - } - // Fallback to last player - return availablePlayers[availablePlayers.length - 1]; - } - - // Updates currentPlayer and currentPosition - function updateCurrentPlayer() { - // Use selected player if index is valid - if (selectedPlayerIndex >= 0) { - let availablePlayers = getAvailablePlayers(); - if (selectedPlayerIndex < availablePlayers.length) { - currentPlayer = availablePlayers[selectedPlayerIndex]; - currentPosition = currentPlayer.position; - Logger.log("MusicManager", "Current player set by index:", currentPlayer ? currentPlayer.identity : "None"); - return ; - } else { - selectedPlayerIndex = -1; // Reset if index is out of range - } - } - // Otherwise, find active player - let newPlayer = findActivePlayer(); - if (newPlayer !== currentPlayer) { - currentPlayer = newPlayer; - currentPosition = currentPlayer ? currentPlayer.position : 0; - } - Logger.log("MusicManager", "Current player updated:", currentPlayer ? currentPlayer.identity : "None"); - } - - // Player control functions - function playPause() { - if (currentPlayer) { - if (currentPlayer.isPlaying) - currentPlayer.pause(); - else - currentPlayer.play(); - } - } - - function isAllPaused() { - let availablePlayers = getAvailablePlayers(); - for (let i = 0; i < availablePlayers.length; i++) { - if (availablePlayers[i].isPlaying) - return false; - - } - return true; - } - - function play() { - if (currentPlayer && currentPlayer.canPlay) - currentPlayer.play(); - - } - - function pause() { - if (currentPlayer && currentPlayer.canPause) - currentPlayer.pause(); - - } - - function next() { - if (currentPlayer && currentPlayer.canGoNext) - currentPlayer.next(); - - } - - function previous() { - if (currentPlayer && currentPlayer.canGoPrevious) - currentPlayer.previous(); - - } - - function seek(position) { - if (currentPlayer && currentPlayer.canSeek) { - currentPlayer.position = position; - currentPosition = position; - } - } - - function seekByRatio(ratio) { - if (currentPlayer && currentPlayer.canSeek && currentPlayer.length > 0) { - let seekPosition = ratio * currentPlayer.length; - currentPlayer.position = seekPosition; - currentPosition = seekPosition; - } - } - - // Initialize - Item { - Component.onCompleted: { - updateCurrentPlayer(); - } - } - - // Updates progress bar every second - Timer { - id: positionTimer - - interval: 1000 - running: currentPlayer && currentPlayer.isPlaying && currentPlayer.length > 0 - repeat: true - onTriggered: { - if (currentPlayer && currentPlayer.isPlaying) - currentPosition = currentPlayer.position; - - } - } - - // Reacts to player list changes - Connections { - function onValuesChanged() { - updateCurrentPlayer(); - } - - target: Mpris.players - } - - Cava { - id: cava - - count: 44 - } - -} diff --git a/config/quickshell/.config/quickshell/Services/NetworkFetch.qml b/config/quickshell/.config/quickshell/Services/NetworkFetch.qml index 8173c96..e8716a0 100644 --- a/config/quickshell/.config/quickshell/Services/NetworkFetch.qml +++ b/config/quickshell/.config/quickshell/Services/NetworkFetch.qml @@ -4,17 +4,6 @@ import Quickshell.Io import qs.Utils Item { - // function fakeFetch(resp, callback, forceIPv4 = false) { - // if (curlProcess.running) { - // Logger.warn("NetworkFetch", "A fetch operation is already in progress."); - // return ; - // } - // fetchedData = ""; - // fetchingCallback = callback; - // curlProcess.command = ["echo", resp]; - // curlProcess.running = true; - // } - id: root property real fetchTimeout: 10 // in seconds @@ -23,7 +12,7 @@ Item { function fetch(url, callback, forceIPv4 = false) { if (curlProcess.running) { - Logger.warn("NetworkFetch", "A fetch operation is already in progress."); + Logger.w("NetworkFetch", "A fetch operation is already in progress."); return ; } fetchedData = ""; @@ -41,24 +30,24 @@ Item { running: false onStarted: { - Logger.log("NetworkFetch", "Process started with command: " + curlProcess.command.join(" ")); + Logger.d("NetworkFetch", "Process started with command: " + curlProcess.command.join(" ")); } onExited: function(exitCode, exitStatus) { if (!fetchingCallback) { - Logger.error("NetworkFetch", "No callback defined for fetch operation."); + Logger.e("NetworkFetch", "No callback defined for fetch operation."); return ; } if (exitCode === 0) { - Logger.log("NetworkFetch", "Fetched data: " + fetchedData); + Logger.d("NetworkFetch", "Fetched data: " + fetchedData); fetchingCallback(true, fetchedData); } else { - Logger.error("NetworkFetch", "Fetch failed with exit code: " + exitCode); + Logger.e("NetworkFetch", "Fetch failed with exit code: " + exitCode); fetchingCallback(false, ""); } } stdout: SplitParser { - splitMarker: "" + splitMarker: "\n" onRead: (data) => { fetchedData += data; } diff --git a/config/quickshell/.config/quickshell/Services/NetworkService.qml b/config/quickshell/.config/quickshell/Services/NetworkService.qml index 47f38be..2b1c8ae 100644 --- a/config/quickshell/.config/quickshell/Services/NetworkService.qml +++ b/config/quickshell/.config/quickshell/Services/NetworkService.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Io import qs.Utils import qs.Services +import qs.Constants Singleton { id: root @@ -23,7 +24,7 @@ Singleton { property bool scanPending: false // Persistent cache - property string cacheFile: CacheService.networkCacheFile + property string cacheFile: Paths.cacheDir + "network.json" readonly property string cachedLastConnected: cacheAdapter.lastConnected readonly property var cachedNetworks: cacheAdapter.knownNetworks @@ -45,7 +46,7 @@ Singleton { } Component.onCompleted: { - Logger.log("Network", "Service initialized") + Logger.i("Network", "Service initialized") syncWifiState() scan() } @@ -94,7 +95,7 @@ Singleton { if (scanning) { // Mark current scan results to be ignored and schedule a new scan - Logger.log("Network", "Scan already in progress, will ignore results and rescan") + Logger.i("Network", "Scan already in progress, will ignore results and rescan") ignoreScanResults = true scanPending = true return @@ -106,7 +107,7 @@ Singleton { // Get existing profiles first, then scan profileCheckProcess.running = true - Logger.log("Network", "Wi-Fi scan in progress...") + Logger.i("Network", "Wi-Fi scan in progress...") } function connect(ssid, password = "") { @@ -218,7 +219,7 @@ Singleton { }) if (root.ethernetConnected !== connected) { root.ethernetConnected = connected - Logger.log("Network", "Ethernet connected:", root.ethernetConnected) + Logger.i("Network", "Ethernet connected:", root.ethernetConnected) } } } @@ -234,7 +235,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { const enabled = text.trim() === "enabled" - Logger.log("Network", "Wi-Fi adapter was detect as enabled:", enabled) + Logger.i("Network", "Wi-Fi adapter was detect as enabled:", enabled) if (SettingsService.wifiEnabled !== enabled) { SettingsService.wifiEnabled = enabled } @@ -250,7 +251,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - Logger.log("Network", "Wi-Fi state change command executed.") + Logger.i("Network", "Wi-Fi state change command executed.") // Re-check the state to ensure it's in sync syncWifiState() } @@ -259,7 +260,7 @@ Singleton { stderr: StdioCollector { onStreamFinished: { if (text.trim()) { - Logger.warn("Network", "Error changing Wi-Fi state: " + text) + Logger.w("Network", "Error changing Wi-Fi state: " + text) } } } @@ -274,7 +275,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { if (root.ignoreScanResults) { - Logger.log("Network", "Ignoring profile check results (new scan requested)") + Logger.i("Network", "Ignoring profile check results (new scan requested)") root.scanning = false // Check if we need to start a new scan @@ -307,7 +308,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { if (root.ignoreScanResults) { - Logger.log("Network", "Ignoring scan results (new scan requested)") + Logger.i("Network", "Ignoring scan results (new scan requested)") root.scanning = false // Check if we need to start a new scan @@ -333,7 +334,7 @@ Singleton { // We know the last 3 fields, so everything else is SSID const lastColonIdx = line.lastIndexOf(":") if (lastColonIdx === -1) { - Logger.warn("Network", "Malformed nmcli output line:", line) + Logger.w("Network", "Malformed nmcli output line:", line) continue } @@ -342,7 +343,7 @@ Singleton { const secondLastColonIdx = remainingLine.lastIndexOf(":") if (secondLastColonIdx === -1) { - Logger.warn("Network", "Malformed nmcli output line:", line) + Logger.w("Network", "Malformed nmcli output line:", line) continue } @@ -351,7 +352,7 @@ Singleton { const thirdLastColonIdx = remainingLine2.lastIndexOf(":") if (thirdLastColonIdx === -1) { - Logger.warn("Network", "Malformed nmcli output line:", line) + Logger.w("Network", "Malformed nmcli output line:", line) continue } @@ -399,15 +400,15 @@ Singleton { if (newNetworks.length > 0 || lostNetworks.length > 0) { if (newNetworks.length > 0) { - Logger.log("Network", "New Wi-Fi SSID discovered:", newNetworks.join(", ")) + Logger.i("Network", "New Wi-Fi SSID discovered:", newNetworks.join(", ")) } if (lostNetworks.length > 0) { - Logger.log("Network", "Wi-Fi SSID disappeared:", lostNetworks.join(", ")) + Logger.i("Network", "Wi-Fi SSID disappeared:", lostNetworks.join(", ")) } - Logger.log("Network", "Total Wi-Fi SSIDs:", Object.keys(networksMap).length) + Logger.i("Network", "Total Wi-Fi SSIDs:", Object.keys(networksMap).length) } - Logger.log("Network", "Wi-Fi scan completed") + Logger.i("Network", "Wi-Fi scan completed") root.networks = networksMap root.scanning = false @@ -424,7 +425,7 @@ Singleton { onStreamFinished: { root.scanning = false if (text.trim()) { - Logger.warn("Network", "Scan error: " + text) + Logger.w("Network", "Scan error: " + text) // If scan fails, retry delayedScanTimer.interval = 5000 @@ -480,7 +481,7 @@ Singleton { root.connecting = false root.connectingTo = "" - Logger.log("Network", `Connected to network: '${connectProcess.ssid}'`) + Logger.i("Network", `Connected to network: '${connectProcess.ssid}'`) ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.connected", { "ssid": connectProcess.ssid })) @@ -509,7 +510,7 @@ Singleton { root.lastError = text.split("\n")[0].trim() } - Logger.warn("Network", "Connect error: " + text) + Logger.w("Network", "Connect error: " + text) } } } @@ -523,7 +524,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - Logger.log("Network", `Disconnected from network: '${disconnectProcess.ssid}'`) + Logger.i("Network", `Disconnected from network: '${disconnectProcess.ssid}'`) ToastService.showNotice(I18n.tr("wifi.panel.title"), I18n.tr("toast.wifi.disconnected", { "ssid": disconnectProcess.ssid })) @@ -542,7 +543,7 @@ Singleton { onStreamFinished: { root.disconnectingFrom = "" if (text.trim()) { - Logger.warn("Network", "Disconnect error: " + text) + Logger.w("Network", "Disconnect error: " + text) } // Still trigger a scan even on error delayedScanTimer.interval = 5000 @@ -588,8 +589,8 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - Logger.log("Network", `Forget network: "${forgetProcess.ssid}"`) - Logger.log("Network", text.trim().replace(/[\r\n]/g, " ")) + Logger.i("Network", `Forget network: "${forgetProcess.ssid}"`) + Logger.i("Network", text.trim().replace(/[\r\n]/g, " ")) // Update both cached and existing status immediately let nets = root.networks @@ -613,7 +614,7 @@ Singleton { onStreamFinished: { root.forgettingNetwork = "" if (text.trim() && !text.includes("No profiles found")) { - Logger.warn("Network", "Forget error: " + text) + Logger.w("Network", "Forget error: " + text) } // Still Trigger a scan even on error delayedScanTimer.interval = 5000 diff --git a/config/quickshell/.config/quickshell/Services/Niri.qml b/config/quickshell/.config/quickshell/Services/Niri.qml index 24ef8c5..282f882 100644 --- a/config/quickshell/.config/quickshell/Services/Niri.qml +++ b/config/quickshell/.config/quickshell/Services/Niri.qml @@ -1,208 +1,519 @@ import QtQuick import Quickshell import Quickshell.Io +import Quickshell.Wayland import qs.Utils pragma Singleton -pragma ComponentBehavior: Bound Singleton { id: root - property var workspaces: [] - property var windows: {} - property int focusedWindowId: -1 - property bool noFocus: focusedWindowId === -1 - property bool inOverview: false - property string focusedWindowTitle: "" - property string focusedWindowAppId: "" - property var onScreenshotCaptured: null + property int floatingWindowPosition: Number.MAX_SAFE_INTEGER + property ListModel workspaces + property var windows: [] + property bool hasFocusedWindow: focusedWindowIndex >= 0 && focusedWindowIndex < windows.length + property int focusedWindowIndex: -1 + property string focusedWindowAppId: hasFocusedWindow ? windows[focusedWindowIndex].appId : "" + property string focusedWindowTitle: hasFocusedWindow ? windows[focusedWindowIndex].title : "" + property string focusedOutput: "" + property ShellScreen focusedScreen: { + if (!focusedOutput) + return null; - function updateFocusedWindowTitle() { - if (windows && windows[focusedWindowId]) { - focusedWindowTitle = windows[focusedWindowId].title || ""; - focusedWindowAppId = windows[focusedWindowId].appId || ""; - } else { - focusedWindowTitle = ""; - focusedWindowAppId = ""; + for (var i = 0; i < Quickshell.screens.length; i++) { + if (Quickshell.screens[i].name === focusedOutput) + return Quickshell.screens[i]; + + } + return null; + } + property bool overviewActive: false + property var outputCache: ({ + }) + property var workspaceCache: ({ + }) + + signal workspaceChanged() + signal activeWindowChanged() + signal windowListChanged() + signal displayScalesChanged() + + function initialize() { + niriEventStream.connected = true; + niriCommandSocket.connected = true; + startEventStream(); + updateOutputs(); + updateWorkspaces(); + updateWindows(); + _queryDisplayScales(); + Logger.i("NiriService", "Service started"); + } + + // command from https://yalter.github.io/niri/niri_ipc/enum.Request.html + function sendSocketCommand(sock, command) { + sock.write(JSON.stringify(command) + "\n"); + sock.flush(); + } + + function startEventStream() { + sendSocketCommand(niriEventStream, "EventStream"); + } + + function updateOutputs() { + sendSocketCommand(niriCommandSocket, "Outputs"); + } + + function updateWorkspaces() { + sendSocketCommand(niriCommandSocket, "Workspaces"); + } + + function updateWindows() { + sendSocketCommand(niriCommandSocket, "Windows"); + } + + function _queryDisplayScales() { + sendSocketCommand(niriCommandSocket, "Outputs"); + } + + function recollectOutputs(outputsData) { + const scales = { + }; + outputCache = { + }; + for (const outputName in outputsData) { + const output = outputsData[outputName]; + if (output && output.name) { + const isConnected = output.logical !== null && output.current_mode !== null; + const logical = output.logical || { + }; + const currentModeIdx = output.current_mode ?? 0; + const modes = output.modes || []; + const currentMode = modes[currentModeIdx] || { + }; + const outputData = { + "name": output.name, + "connected": isConnected, + "scale": logical.scale || 1, + "width": logical.width || 0, + "height": logical.height || 0, + "x": logical.x || 0, + "y": logical.y || 0, + "physical_width": (output.physical_size && output.physical_size[0]) || 0, + "physical_height": (output.physical_size && output.physical_size[1]) || 0, + "refresh_rate": currentMode.refresh_rate || 0, + "vrr_supported": output.vrr_supported || false, + "vrr_enabled": output.vrr_enabled || false, + "transform": logical.transform || "Normal" + }; + outputCache[output.name] = outputData; + scales[output.name] = outputData; + } } } - function getFocusedWindow() { - return (windows && windows[focusedWindowId]) || null; + function _recollectWorkspaces(workspacesData) { + const workspacesList = []; + workspaceCache = { + }; + for (const ws of workspacesData) { + const wsData = { + "id": ws.id, + "idx": ws.idx, + "name": ws.name || "", + "output": ws.output || "", + "isFocused": ws.is_focused === true, + "isActive": ws.is_active === true, + "isUrgent": ws.is_urgent === true, + "isOccupied": ws.active_window_id ? true : false + }; + workspacesList.push(wsData); + workspaceCache[ws.id] = wsData; + if (wsData.isFocused) + focusedOutput = wsData.output || ""; + + } + workspacesList.sort((a, b) => { + if (a.output !== b.output) + return a.output.localeCompare(b.output); + + return a.idx - b.idx; + }); + workspaces.clear(); + for (var i = 0; i < workspacesList.length; i++) { + workspaces.append(workspacesList[i]); + } + workspaceChanged(); } - Component.onCompleted: { - eventStream.running = true; + function getWindowPosition(layout) { + if (layout.pos_in_scrolling_layout) + return { + "x": layout.pos_in_scrolling_layout[0], + "y": layout.pos_in_scrolling_layout[1] + }; + else + return { + "x": floatingWindowPosition, + "y": floatingWindowPosition + }; } - Process { - id: workspaceProcess + function getWindowOutput(win) { + for (var i = 0; i < workspaces.count; i++) { + if (workspaces.get(i).id === win.workspace_id) + return workspaces.get(i).output; - running: false - command: ["niri", "msg", "--json", "workspaces"] + } + return null; + } - stdout: SplitParser { + function getWindowData(win) { + return { + "id": win.id, + "title": win.title || "", + "appId": win.app_id || "", + "workspaceId": win.workspace_id || -1, + "isFocused": win.is_focused === true, + "output": getWindowOutput(win) || "", + "position": getWindowPosition(win.layout) + }; + } + + function toSortedWindowList(windowList) { + return windowList.map((win) => { + const workspace = workspaceCache[win.workspaceId]; + const output = (workspace && workspace.output) ? outputCache[workspace.output] : null; + return { + "window": win, + "workspaceIdx": workspace ? workspace.idx : 0, + "outputX": output ? output.x : 0, + "outputY": output ? output.y : 0 + }; + }).sort((a, b) => { + // Sort by output position first + if (a.outputX !== b.outputX) + return a.outputX - b.outputX; + + if (a.outputY !== b.outputY) + return a.outputY - b.outputY; + + // Then by workspace index + if (a.workspaceIdx !== b.workspaceIdx) + return a.workspaceIdx - b.workspaceIdx; + + // Then by window position + if (a.window.position.x !== b.window.position.x) + return a.window.position.x - b.window.position.x; + + if (a.window.position.y !== b.window.position.y) + return a.window.position.y - b.window.position.y; + + // Finally by window ID to ensure consistent ordering + return a.window.id - b.window.id; + }).map((info) => { + return info.window; + }); + } + + function recollectWindows(windowsData) { + const windowsList = []; + for (const win of windowsData) { + windowsList.push(getWindowData(win)); + } + windows = toSortedWindowList(windowsList); + windowListChanged(); + // Find focused window index in the SORTED windows array + focusedWindowIndex = -1; + for (var i = 0; i < windows.length; i++) { + if (windows[i].isFocused) { + focusedWindowIndex = i; + break; + } + } + activeWindowChanged(); + } + + function _handleWindowOpenedOrChanged(eventData) { + try { + const windowData = eventData.window; + const existingIndex = windows.findIndex((w) => { + return w.id === windowData.id; + }); + const newWindow = getWindowData(windowData); + // Find the previously focused window ID before any modifications + const previouslyFocusedId = focusedWindowIndex >= 0 && focusedWindowIndex < windows.length ? windows[focusedWindowIndex].id : null; + if (existingIndex >= 0) + windows[existingIndex] = newWindow; + else + windows.push(newWindow); + windows = toSortedWindowList(windows); + if (newWindow.isFocused) { + focusedWindowIndex = windows.findIndex((w) => { + return w.id === windowData.id; + }); + // Clear focus on the previously focused window by ID (not index, since list was re-sorted) + if (previouslyFocusedId !== null && previouslyFocusedId !== windowData.id) { + const oldFocusedWindow = windows.find((w) => { + return w.id === previouslyFocusedId; + }); + if (oldFocusedWindow) + oldFocusedWindow.isFocused = false; + + } + activeWindowChanged(); + } + windowListChanged(); + workspaceUpdateTimer.restart(); + } catch (e) { + Logger.e("NiriService", "Error handling WindowOpenedOrChanged:", e); + } + } + + function _handleWindowClosed(eventData) { + try { + const windowId = eventData.id; + const windowIndex = windows.findIndex((w) => { + return w.id === windowId; + }); + if (windowIndex >= 0) { + if (windowIndex === focusedWindowIndex) { + focusedWindowIndex = -1; + activeWindowChanged(); + } else if (focusedWindowIndex > windowIndex) { + focusedWindowIndex--; + } + windows.splice(windowIndex, 1); + windowListChanged(); + workspaceUpdateTimer.restart(); + } + } catch (e) { + Logger.e("NiriService", "Error handling WindowClosed:", e); + } + } + + function _handleWindowsChanged(eventData) { + try { + const windowsData = eventData.windows; + recollectWindows(windowsData); + } catch (e) { + Logger.e("NiriService", "Error handling WindowsChanged:", e); + } + } + + function _handleWindowFocusChanged(eventData) { + try { + const focusedId = eventData.id; + if (windows[focusedWindowIndex]) + windows[focusedWindowIndex].isFocused = false; + + if (focusedId) { + const newIndex = windows.findIndex((w) => { + return w.id === focusedId; + }); + if (newIndex >= 0 && newIndex < windows.length) + windows[newIndex].isFocused = true; + + focusedWindowIndex = newIndex >= 0 ? newIndex : -1; + } else { + focusedWindowIndex = -1; + } + activeWindowChanged(); + } catch (e) { + Logger.e("NiriService", "Error handling WindowFocusChanged:", e); + } + } + + function _handleWindowLayoutsChanged(eventData) { + try { + for (const change of eventData.changes) { + const windowId = change[0]; + const layout = change[1]; + const window = windows.find((w) => { + return w.id === windowId; + }); + if (window) + window.position = getWindowPosition(layout); + + } + windows = toSortedWindowList(windows); + windowListChanged(); + } catch (e) { + Logger.e("NiriService", "Error handling WindowLayoutChanged:", e); + } + } + + function _handleOverviewOpenedOrClosed(eventData) { + try { + overviewActive = eventData.is_open; + } catch (e) { + Logger.e("NiriService", "Error handling OverviewOpenedOrClosed:", e); + } + } + + function _handleScreenshotCaptured(eventData) { + try { + const filePath = eventData.path; + if (filePath) + Quickshell.execDetached(["screenshot-script", "edit", filePath]); + + } catch (e) { + Logger.e("NiriService", "Error handling ScreenshotCaptured:", e); + } + } + + function switchToWorkspace(workspace) { + try { + Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspace.idx.toString()]); + } catch (e) { + Logger.e("NiriService", "Failed to switch workspace:", e); + } + } + + function scrollWorkspaceContent(direction) { + try { + var action = direction < 0 ? "focus-column-left" : "focus-column-right"; + Quickshell.execDetached(["niri", "msg", "action", action]); + } catch (e) { + Logger.e("NiriService", "Failed to scroll workspace content:", e); + } + } + + function focusWindow(window) { + try { + Quickshell.execDetached(["niri", "msg", "action", "focus-window", "--id", window.id.toString()]); + } catch (e) { + Logger.e("NiriService", "Failed to switch window:", e); + } + } + + function closeWindow(window) { + try { + Quickshell.execDetached(["niri", "msg", "action", "close-window", "--id", window.id.toString()]); + } catch (e) { + Logger.e("NiriService", "Failed to close window:", e); + } + } + + function turnOffMonitors() { + try { + Quickshell.execDetached(["niri", "msg", "action", "power-off-monitors"]); + } catch (e) { + Logger.e("NiriService", "Failed to turn off monitors:", e); + } + } + + function turnOnMonitors() { + try { + Quickshell.execDetached(["niri", "msg", "action", "power-on-monitors"]); + } catch (e) { + Logger.e("NiriService", "Failed to turn on monitors:", e); + } + } + + function logout() { + try { + Quickshell.execDetached(["niri", "msg", "action", "quit", "--skip-confirmation"]); + } catch (e) { + Logger.e("NiriService", "Failed to logout:", e); + } + } + + function getFocusedScreen() { + // On niri the code below only works when you have an actual app selected on that screen. + return null; + } + + function spawn(command) { + try { + const niriCommand = ["niri", "msg", "action", "spawn", "--"].concat(command); + Logger.d("NiriService", "Calling niri spawn: " + niriCommand.join(" ")); + Quickshell.execDetached(niriCommand); + } catch (e) { + Logger.e("NiriService", "Failed to spawn command:", e); + } + } + + Component.onCompleted: initialize() + + Timer { + id: workspaceUpdateTimer + + interval: 50 + repeat: false + onTriggered: updateWorkspaces() + } + + Socket { + id: niriCommandSocket + + path: Quickshell.env("NIRI_SOCKET") + connected: false + + parser: SplitParser { onRead: function(line) { try { - const workspacesData = JSON.parse(line); - const workspacesList = []; - for (const ws of workspacesData) { - workspacesList.push({ - "id": ws.id, - "idx": ws.idx, - "name": ws.name || "", - "output": ws.output || "", - "isFocused": ws.is_focused === true, - "isActive": ws.is_active === true, - "isUrgent": ws.is_urgent === true, - "activeWindowId": ws.active_window_id - }); + const data = JSON.parse(line); + if (data && data.Ok) { + const res = data.Ok; + if (res.Windows) + recollectWindows(res.Windows); + else if (res.Outputs) + recollectOutputs(res.Outputs); + else if (res.Workspaces) + _recollectWorkspaces(res.Workspaces); + } else { + Logger.e("NiriService", "Niri returned an error:", data.Err, line); } - workspacesList.sort((a, b) => { - if (a.output !== b.output) - return a.output.localeCompare(b.output); - - return a.id - b.id; - }); - root.workspaces = workspacesList; } catch (e) { - Logger.error("Niri", "Failed to parse workspaces:", e, line); + Logger.e("NiriService", "Failed to parse data from socket:", e, line); + return ; } } } } - Process { - id: eventStream + Socket { + id: niriEventStream - running: false - command: ["niri", "msg", "--json", "event-stream"] + path: Quickshell.env("NIRI_SOCKET") + connected: false - stdout: SplitParser { + parser: SplitParser { onRead: (data) => { try { const event = JSON.parse(data.trim()); - if (event.WorkspacesChanged) { - workspaceProcess.running = true; - } - if (event.WindowsChanged) { - try { - const windowsData = event.WindowsChanged.windows; - const windowsMap = {}; - for (const win of windowsData) { - if (win.is_focused === true) { - root.focusedWindowId = win.id; - } - windowsMap[win.id] = { - "title": win.title || "", - "appId": win.app_id || "", - "workspaceId": win.workspace_id || null, - "isFocused": win.is_focused === true - }; - } - root.windows = windowsMap; - root.updateFocusedWindowTitle(); - } catch (e) { - Logger.error("Niri", "Error parsing windows event:", e); - } - } - if (event.WorkspaceActivated) { - workspaceProcess.running = true; - } - if (event.WindowFocusChanged) { - try { - const focusedId = event.WindowFocusChanged.id; - if (focusedId) { - if (root.windows[focusedId]) { - root.focusedWindowId = focusedId; - } else { - root.focusedWindowId = -1; - } - - } else { - root.focusedWindowId = -1; - } - root.updateFocusedWindowTitle(); - } catch (e) { - Logger.error("Niri", "Error parsing window focus event:", e); - } - } - if (event.OverviewOpenedOrClosed) { - try { - root.inOverview = event.OverviewOpenedOrClosed.is_open === true; - } catch (e) { - Logger.error("Niri", "Error parsing overview state:", e); - } - } - if (event.WindowOpenedOrChanged) { - try { - const targetWin = event.WindowOpenedOrChanged.window; - const id = targetWin.id; - const isFocused = targetWin.is_focused === true; - let needUpdateTitle = false; - if (id) { - if (root.windows && root.windows[id]) { - const win = root.windows[id]; - // Update existing window - needUpdateTitle = win.title !== targetWin.title; - win.title = targetWin.title || win.title; - win.appId = targetWin.app_id || win.appId; - win.workspaceId = targetWin.workspace_id || win.workspaceId; - win.isFocused = isFocused; - } else { - // New window - const newWin = { - "title": targetWin.title || "", - "appId": targetWin.app_id || "", - "workspaceId": targetWin.workspace_id || null, - "isFocused": isFocused - }; - root.windows[id] = targetWin; - } - if (isFocused) { - if (root.focusedWindowId !== id || needUpdateTitle){ - root.focusedWindowId = id; - root.updateFocusedWindowTitle(); - } - } - - } - } catch (e) { - Logger.error("Niri", "Error parsing window opened/changed event:", e); - } - } - if (event.WindowClosed) { - try { - const closedId = event.WindowClosed.id; - if (closedId && (root.windows && root.windows[closedId])) { - delete root.windows[closedId]; - if (root.focusedWindowId === closedId) { - root.focusedWindowId = -1; - root.updateFocusedWindowTitle(); - } - } - } catch (e) { - Logger.error("Niri", "Error parsing window closed event:", e); - } - } - if (event.ScreenshotCaptured) { - try { - const path = event.ScreenshotCaptured.path || ""; - if (!path) return; - if (root.onScreenshotCaptured && typeof root.onScreenshotCaptured === "function") { - root.onScreenshotCaptured(path); - } - } catch (e) { - Logger.error("Niri", "Error parsing screenshot captured event:", e); - } - } + if (event.WorkspacesChanged) + _recollectWorkspaces(event.WorkspacesChanged.workspaces); + else if (event.WindowOpenedOrChanged) + _handleWindowOpenedOrChanged(event.WindowOpenedOrChanged); + else if (event.WindowClosed) + _handleWindowClosed(event.WindowClosed); + else if (event.WindowsChanged) + _handleWindowsChanged(event.WindowsChanged); + else if (event.WorkspaceActivated) + workspaceUpdateTimer.restart(); + else if (event.WindowFocusChanged) + _handleWindowFocusChanged(event.WindowFocusChanged); + else if (event.WindowLayoutsChanged) + _handleWindowLayoutsChanged(event.WindowLayoutsChanged); + else if (event.OverviewOpenedOrClosed) + _handleOverviewOpenedOrClosed(event.OverviewOpenedOrClosed); + else if (event.OutputsChanged) + _queryDisplayScales(); + else if (event.ConfigLoaded) + _queryDisplayScales(); + else if (event.ScreenshotCaptured) + _handleScreenshotCaptured(event.ScreenshotCaptured); } catch (e) { - Logger.error("Niri", "Error parsing event stream:", e, data); + Logger.e("NiriService", "Error parsing event stream:", e, data); } } } } + workspaces: ListModel { + } + } diff --git a/config/quickshell/.config/quickshell/Services/NotificationService.qml b/config/quickshell/.config/quickshell/Services/NotificationService.qml index 3204ed5..48c2860 100644 --- a/config/quickshell/.config/quickshell/Services/NotificationService.qml +++ b/config/quickshell/.config/quickshell/Services/NotificationService.qml @@ -5,10 +5,11 @@ import QtQuick.Window import Quickshell import Quickshell.Io import Quickshell.Services.Notifications +import Quickshell.Wayland +import "./sha256.js" as Checksum import qs.Utils import qs.Services import qs.Constants -import "../Utils/sha256.js" as Checksum Singleton { id: root @@ -16,254 +17,469 @@ Singleton { // Configuration property int maxVisible: 5 property int maxHistory: 100 - property string historyFile: CacheService.notificationsCacheFile - property string cacheDirImagesNotifications: Quickshell.env("HOME") + "/.cache/quickshell/notifications/" - property real lowUrgencyDuration: 3 - property real normalUrgencyDuration: 8 - property real criticalUrgencyDuration: 15 + property string historyFile: Paths.cacheDir + "notifications.json" + + // State + property real lastSeenTs: ShellState.notificationsState.lastSeenTs || 0 + property bool doNotDisturb: ShellState.notificationsState.doNotDisturb || false // Models property ListModel activeList: ListModel {} property ListModel historyList: ListModel {} // Internal state - property var activeMap: ({}) - property var imageQueue: [] + property var activeNotifications: ({}) // Maps internal ID to {notification, watcher, metadata} + property var quickshellIdToInternalId: ({}) - // Performance optimization: Track notification metadata separately - property var notificationMetadata: ({}) // Stores timestamp and duration for each notification + // Rate limiting for notification sounds (minimum 100ms between sounds) + property var lastSoundTime: 0 + readonly property int minSoundInterval: 100 - PanelWindow { - implicitHeight: 1 - implicitWidth: 1 - color: Color.transparent - mask: Region {} + // Notification server + property var notificationServerLoader: null - Image { - id: cacher - width: 64 - height: 64 - visible: true - cache: false - asynchronous: true - mipmap: true - antialiasing: true + // Setting + property bool notificationEnabled: true + property bool saveToHistory: true + property int lowUrgencyDuration: 3 + property int normalUrgencyDuration: 8 + property int criticalUrgencyDuration: 15 + property bool respectExpireTimeout: true - onStatusChanged: { - if (imageQueue.length === 0) - return - const req = imageQueue[0] + Component { + id: notificationServerComponent + NotificationServer { + keepOnReload: false + imageSupported: true + actionsSupported: true + onNotification: notification => handleNotification(notification) + } + } - if (status === Image.Ready) { - Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications]) - grabToImage(result => { - if (result.saveToFile(req.dest)) - updateImagePath(req.imageId, req.dest) - processNextImage() - }) - } else if (status === Image.Error) { - processNextImage() - } + Component { + id: notificationWatcherComponent + Connections { + property var targetNotification + property var targetDataId + target: targetNotification + + function onSummaryChanged() { + updateNotificationFromObject(targetDataId); } - - function processNextImage() { - imageQueue.shift() - if (imageQueue.length > 0) { - source = imageQueue[0].src - } else { - source = "" - } + function onBodyChanged() { + updateNotificationFromObject(targetDataId); + } + function onAppNameChanged() { + updateNotificationFromObject(targetDataId); + } + function onUrgencyChanged() { + updateNotificationFromObject(targetDataId); + } + function onAppIconChanged() { + updateNotificationFromObject(targetDataId); + } + function onImageChanged() { + updateNotificationFromObject(targetDataId); + } + function onActionsChanged() { + updateNotificationFromObject(targetDataId); } } } - // Notification server - NotificationServer { - keepOnReload: false - imageSupported: true - actionsSupported: true - onNotification: notification => handleNotification(notification) + function updateNotificationServer() { + if (notificationServerLoader) { + notificationServerLoader.destroy(); + notificationServerLoader = null; + } + + if (root.notificationEnabled !== false) { + notificationServerLoader = notificationServerComponent.createObject(root); + } + } + + Component.onCompleted: { + // Start the notification server + updateNotificationServer(); + } + + // Helper function to generate content-based ID for deduplication + function getContentId(summary, body, appName) { + return Checksum.sha256(JSON.stringify({ + "summary": summary || "", + "body": body || "", + "app": appName || "" + })); } // Main handler function handleNotification(notification) { - const data = createData(notification) - addToHistory(data) + const quickshellId = notification.id; + const data = createData(notification); - if (SettingsService.notifications.doNotDisturb) - return - - activeMap[data.id] = notification - notification.tracked = true - notification.closed.connect(() => removeActive(data.id)) - - // Store metadata for efficient progress calculation - const durations = [lowUrgencyDuration * 1000, normalUrgencyDuration * 1000, criticalUrgencyDuration * 1000] - - let expire = 0 - if (data.expireTimeout === 0) { - expire = -1 // Never expire - } else if (data.expireTimeout > 0) { - expire = data.expireTimeout - } else { - expire = durations[data.urgency] + // Check if we should save to history based on urgency + const saveToHistorySettings = root.saveToHistory; + if (saveToHistorySettings && !notification.transient) { + let shouldSave = true; + switch (data.urgency) { + case 0: // low + shouldSave = saveToHistorySettings.low !== false; + break; + case 1: // normal + shouldSave = saveToHistorySettings.normal !== false; + break; + case 2: // critical + shouldSave = saveToHistorySettings.critical !== false; + break; + } + if (shouldSave) { + addToHistory(data); + } + } else if (!notification.transient) { + // Default behavior: save all if settings not configured + addToHistory(data); } - notificationMetadata[data.id] = { - "timestamp": data.timestamp.getTime(), - "duration": expire, - "urgency": data.urgency + if (root.doNotDisturb) + return; + + // Check if this is a replacement notification + const existingInternalId = quickshellIdToInternalId[quickshellId]; + if (existingInternalId && activeNotifications[existingInternalId]) { + updateExistingNotification(existingInternalId, notification, data); + return; } - activeList.insert(0, data) + // Check for duplicate content + const duplicateId = findDuplicateNotification(data); + if (duplicateId) { + removeNotification(duplicateId); + } + + // Add new notification + addNewNotification(quickshellId, notification, data); + } + + function updateExistingNotification(internalId, notification, data) { + const index = findNotificationIndex(internalId); + if (index < 0) + return; + const existing = activeList.get(index); + const oldTimestamp = existing.timestamp; + const oldProgress = existing.progress; + + // Update properties (keeping original timestamp and progress) + activeList.setProperty(index, "summary", data.summary); + activeList.setProperty(index, "summaryMarkdown", data.summaryMarkdown); + activeList.setProperty(index, "body", data.body); + activeList.setProperty(index, "bodyMarkdown", data.bodyMarkdown); + activeList.setProperty(index, "appName", data.appName); + activeList.setProperty(index, "urgency", data.urgency); + activeList.setProperty(index, "expireTimeout", data.expireTimeout); + activeList.setProperty(index, "originalImage", data.originalImage); + activeList.setProperty(index, "cachedImage", data.cachedImage); + activeList.setProperty(index, "actionsJson", data.actionsJson); + activeList.setProperty(index, "timestamp", oldTimestamp); + activeList.setProperty(index, "progress", oldProgress); + + // Update stored notification object + const notifData = activeNotifications[internalId]; + notifData.notification = notification; + + // Deep copy actions to preserve them even if QML object clears list + var safeActions = []; + if (notification.actions) { + for (var i = 0; i < notification.actions.length; i++) { + safeActions.push({ + "identifier": notification.actions[i].identifier, + "actionObject": notification.actions[i] + }); + } + } + notifData.cachedActions = safeActions; + notifData.metadata.originalId = data.originalId; + + notification.tracked = true; + + function onClosed() { + userDismissNotification(internalId); + } + notification.closed.connect(onClosed); + notifData.onClosed = onClosed; + + // Update metadata + notifData.metadata.urgency = data.urgency; + notifData.metadata.duration = calculateDuration(data); + } + + function addNewNotification(quickshellId, notification, data) { + // Map IDs + quickshellIdToInternalId[quickshellId] = data.id; + + // Create watcher + const watcher = notificationWatcherComponent.createObject(root, { + "targetNotification": notification, + "targetDataId": data.id + }); + + // Deep copy actions + var safeActions = []; + if (notification.actions) { + for (var i = 0; i < notification.actions.length; i++) { + safeActions.push({ + "identifier": notification.actions[i].identifier, + "actionObject": notification.actions[i] + }); + } + } + + // Store notification data + activeNotifications[data.id] = { + "notification": notification, + "watcher": watcher, + "cachedActions": safeActions // Cache actions + , + "metadata": { + "originalId": data.originalId // Store original ID + , + "timestamp": data.timestamp.getTime(), + "duration": calculateDuration(data), + "urgency": data.urgency, + "paused": false, + "pauseTime": 0 + } + }; + + notification.tracked = true; + + function onClosed() { + userDismissNotification(data.id); + } + notification.closed.connect(onClosed); + activeNotifications[data.id].onClosed = onClosed; + + // Add to list + activeList.insert(0, data); + + // Remove overflow while (activeList.count > maxVisible) { - const last = activeList.get(activeList.count - 1) - activeMap[last.id]?.dismiss() - activeList.remove(activeList.count - 1) - delete notificationMetadata[last.id] + const last = activeList.get(activeList.count - 1); + // Overflow only removes from ACTIVE view, but keeps it for history + activeNotifications[last.id]?.notification?.dismiss(); // Visually dismiss + activeList.remove(activeList.count - 1); + // DO NOT call cleanupNotification here, we want to keep it for history actions } } + function findDuplicateNotification(data) { + const contentId = getContentId(data.summary, data.body, data.appName); + + for (var i = 0; i < activeList.count; i++) { + const existing = activeList.get(i); + const existingContentId = getContentId(existing.summary, existing.body, existing.appName); + if (existingContentId === contentId) { + return existing.id; + } + } + return null; + } + + function calculateDuration(data) { + const durations = [root.lowUrgencyDuration * 1000 || 3000, root.normalUrgencyDuration * 1000 || 8000, root.criticalUrgencyDuration * 1000 || 15000]; + + if (root.respectExpireTimeout) { + if (data.expireTimeout === 0) + return -1; // Never expire + if (data.expireTimeout > 0) + return data.expireTimeout; + } + + return durations[data.urgency]; + } + function createData(n) { - const time = new Date() + const time = new Date(); const id = Checksum.sha256(JSON.stringify({ "summary": n.summary, "body": n.body, "app": n.appName, "time": time.getTime() - })) + })); - const image = n.image || getIcon(n.appIcon) - const imageId = generateImageId(n, image) - queueImage(image, imageId) + const image = n.image || getIcon(n.appIcon); + const imageId = generateImageId(n, image); + queueImage(image, n.appName || "", n.summary || "", id); return { "id": id, - "summary": (n.summary || ""), - "body": stripTags(n.body || ""), - "appName": getAppName(n.appName), + "summary": processNotificationText(n.summary || ""), + "summaryMarkdown": processNotificationMarkdown(n.summary || ""), + "body": processNotificationText(n.body || ""), + "bodyMarkdown": processNotificationMarkdown(n.body || ""), + "appName": getAppName(n.appName || n.desktopEntry || ""), "urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency, "expireTimeout": n.expireTimeout, "timestamp": time, "progress": 1.0, "originalImage": image, - "cachedImage": imageId ? (cacheDirImagesNotifications + imageId + ".png") : image, + "cachedImage": image // Start with original, update when cached + , + "originalId": n.originalId || n.id || 0 // Ensure originalId is passed through + , "actionsJson": JSON.stringify((n.actions || []).map(a => ({ - "text": a.text || "Action", + "text": (a.text || "").trim() || "Action", "identifier": a.identifier || "" }))) - } + }; } - function queueImage(path, imageId) { - if (!path || !path.startsWith("image://") || !imageId) - return - - const dest = cacheDirImagesNotifications + imageId + ".png" - - for (const req of imageQueue) { - if (req.imageId === imageId) - return - } - - imageQueue.push({ - "src": path, - "dest": dest, - "imageId": imageId - }) - - if (imageQueue.length === 1) - cacher.source = path - } - - function updateImagePath(id, path) { - updateModel(activeList, id, "cachedImage", path) - updateModel(historyList, id, "cachedImage", path) - saveHistory() - } - - function updateModel(model, id, prop, value) { - for (var i = 0; i < model.count; i++) { - if (model.get(i).id === id) { - model.setProperty(i, prop, value) - break - } - } - } - - function removeActive(id) { + function findNotificationIndex(internalId) { for (var i = 0; i < activeList.count; i++) { - if (activeList.get(i).id === id) { - activeList.remove(i) - delete activeMap[id] - delete notificationMetadata[id] - break + if (activeList.get(i).id === internalId) { + return i; + } + } + return -1; + } + + function updateNotificationFromObject(internalId) { + const notifData = activeNotifications[internalId]; + if (!notifData) + return; + const index = findNotificationIndex(internalId); + if (index < 0) + return; + const data = createData(notifData.notification); + const existing = activeList.get(index); + + // Update properties (keeping timestamp and progress) + activeList.setProperty(index, "summary", data.summary); + activeList.setProperty(index, "summaryMarkdown", data.summaryMarkdown); + activeList.setProperty(index, "body", data.body); + activeList.setProperty(index, "bodyMarkdown", data.bodyMarkdown); + activeList.setProperty(index, "appName", data.appName); + activeList.setProperty(index, "urgency", data.urgency); + activeList.setProperty(index, "expireTimeout", data.expireTimeout); + activeList.setProperty(index, "originalImage", data.originalImage); + activeList.setProperty(index, "cachedImage", data.cachedImage); + activeList.setProperty(index, "actionsJson", data.actionsJson); + + // Update metadata + notifData.metadata.urgency = data.urgency; + notifData.metadata.duration = calculateDuration(data); + } + + function removeNotification(id) { + const index = findNotificationIndex(id); + if (index >= 0) { + activeList.remove(index); + } + cleanupNotification(id); + } + + function cleanupNotification(id) { + const notifData = activeNotifications[id]; + if (notifData) { + notifData.watcher?.destroy(); + delete activeNotifications[id]; + } + + // Clean up quickshell ID mapping + for (const qsId in quickshellIdToInternalId) { + if (quickshellIdToInternalId[qsId] === id) { + delete quickshellIdToInternalId[qsId]; + break; } } } - // Optimized batch progress update + // Progress updates Timer { - interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100) + interval: 50 repeat: true running: activeList.count > 0 onTriggered: updateAllProgress() } function updateAllProgress() { - const now = Date.now() - const toRemove = [] - const updates = [] // Batch updates + const now = Date.now(); + const toRemove = []; - // Collect all updates first for (var i = 0; i < activeList.count; i++) { - const notif = activeList.get(i) - const meta = notificationMetadata[notif.id] - - if (!meta || meta.duration === -1) - continue - - // Skip infinite notifications - const elapsed = now - meta.timestamp - const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0) + const notif = activeList.get(i); + const notifData = activeNotifications[notif.id]; + if (!notifData) + continue; + const meta = notifData.metadata; + if (meta.duration === -1 || meta.paused) + continue; + const elapsed = now - meta.timestamp; + const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0); if (progress <= 0) { - toRemove.push(notif.id) + toRemove.push(notif.id); } else if (Math.abs(notif.progress - progress) > 0.005) { - // Only update if change is significant - updates.push({ - "index": i, - "progress": progress - }) + activeList.setProperty(i, "progress", progress); } } - // Apply batch updates - for (const update of updates) { - activeList.setProperty(update.index, "progress", update.progress) - } - - // Remove expired notifications (one at a time to allow animation) if (toRemove.length > 0) { - animateAndRemove(toRemove[0]) + animateAndRemove(toRemove[0]); + } + } + + // Image handling + function queueImage(path, appName, summary, notificationId) { + if (!path || !notificationId) + return; + + // Cache image:// URIs and temporary file paths (e.g. /tmp/ from Chromium) + const filePath = path.startsWith("file://") ? path.substring(7) : path; + const isImageUri = path.startsWith("image://"); + const isTempFile = path.startsWith("/") && filePath.startsWith("/tmp/"); + + if (!isImageUri && !isTempFile) + return; + + ImageCacheService.getNotificationIcon(path, appName, summary, function (cachedPath, success) { + if (success && cachedPath) { + updateImagePath(notificationId, "file://" + cachedPath); + } + }); + } + + function updateImagePath(notificationId, path) { + updateModel(activeList, notificationId, "cachedImage", path); + updateModel(historyList, notificationId, "cachedImage", path); + saveHistory(); + } + + function updateModel(model, notificationId, prop, value) { + for (var i = 0; i < model.count; i++) { + if (model.get(i).id === notificationId) { + model.setProperty(i, prop, value); + break; + } } } // History management function addToHistory(data) { - historyList.insert(0, data) + historyList.insert(0, data); while (historyList.count > maxHistory) { - const old = historyList.get(historyList.count - 1) - if (old.cachedImage && !old.cachedImage.startsWith("image://")) { - Quickshell.execDetached(["rm", "-f", old.cachedImage]) + const old = historyList.get(historyList.count - 1); + // Only delete cached images that are in our cache directory + const cachedPath = old.cachedImage ? old.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); } - historyList.remove(historyList.count - 1) + historyList.remove(historyList.count - 1); } - saveHistory() + saveHistory(); } - // Persistence + // Persistence - History FileView { id: historyFileView path: historyFile @@ -271,7 +487,7 @@ Singleton { onLoaded: loadHistory() onLoadFailed: error => { if (error === 2) - writeAdapter() + writeAdapter(); } JsonAdapter { @@ -287,188 +503,473 @@ Singleton { } function saveHistory() { - saveTimer.restart() + saveTimer.restart(); } function performSaveHistory() { try { - const items = [] + const items = []; for (var i = 0; i < historyList.count; i++) { - const n = historyList.get(i) - const copy = Object.assign({}, n) - copy.timestamp = n.timestamp.getTime() - items.push(copy) + const n = historyList.get(i); + const copy = Object.assign({}, n); + copy.timestamp = n.timestamp.getTime(); + items.push(copy); } - adapter.notifications = items - historyFileView.writeAdapter() + adapter.notifications = items; + historyFileView.writeAdapter(); } catch (e) { - Logger.error("Notifications", "Save history failed:", e) + Logger.e("Notifications", "Save history failed:", e); } } function loadHistory() { try { - historyList.clear() + historyList.clear(); for (const item of adapter.notifications || []) { - const time = new Date(item.timestamp) + const time = new Date(item.timestamp); - let cachedImage = item.cachedImage || "" - if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) { - const imageId = generateImageId(item, item.originalImage) - if (imageId) { - cachedImage = cacheDirImagesNotifications + imageId + ".png" - } + // Use the cached image if it exists and starts with file://, otherwise use originalImage + let cachedImage = item.cachedImage || ""; + if (!cachedImage || (!cachedImage.startsWith("file://") && !cachedImage.startsWith("/"))) { + cachedImage = item.originalImage || ""; } historyList.append({ "id": item.id || "", "summary": item.summary || "", + "summaryMarkdown": processNotificationMarkdown(item.summary || ""), "body": item.body || "", + "bodyMarkdown": processNotificationMarkdown(item.body || ""), "appName": item.appName || "", "urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency, "timestamp": time, "originalImage": item.originalImage || "", - "cachedImage": cachedImage - }) + "cachedImage": cachedImage, + "actionsJson": item.actionsJson || "[]", + "originalId": item.originalId || 0 + }); } } catch (e) { - Logger.error("Notifications", "Load failed:", e) + Logger.e("Notifications", "Load failed:", e); } } + function updateLastSeenTs() { + ShellState.notificationsState = { + lastSeenTs: Time.timestamp * 1000, + doNotDisturb: root.doNotDisturb + }; + } + + function toggleDoNotDisturb() { + ShellState.notificationsState = { + lastSeenTs: root.lastSeenTs, + doNotDisturb: !root.doNotDisturb + }; + } + + // Utility functions function getAppName(name) { if (!name || name.trim() === "") - return "Unknown" - - name = name.trim() + return "Unknown"; + name = name.trim(); if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) { - const parts = name.split(".") - let appPart = parts[parts.length - 1] + const parts = name.split("."); + let appPart = parts[parts.length - 1]; if (!appPart || appPart === "app" || appPart === "desktop") { - appPart = parts[parts.length - 2] || parts[0] + appPart = parts[parts.length - 2] || parts[0]; } - if (appPart) { - name = appPart - } + if (appPart) + name = appPart; } if (name.includes(".")) { - const parts = name.split(".") - let displayName = parts[parts.length - 1] + const parts = name.split("."); + let displayName = parts[parts.length - 1]; if (!displayName || /^\d+$/.test(displayName)) { - displayName = parts[parts.length - 2] || parts[0] + displayName = parts[parts.length - 2] || parts[0]; } if (displayName) { - displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1) - displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2') - displayName = displayName.replace(/app$/i, '').trim() - displayName = displayName.replace(/desktop$/i, '').trim() - displayName = displayName.replace(/flatpak$/i, '').trim() + displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1); + displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2'); + displayName = displayName.replace(/app$/i, '').trim(); + displayName = displayName.replace(/desktop$/i, '').trim(); + displayName = displayName.replace(/flatpak$/i, '').trim(); if (!displayName) { - displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1) + displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1); } } - return displayName || name + return displayName || name; } - let displayName = name.charAt(0).toUpperCase() + name.slice(1) - displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2') - displayName = displayName.replace(/app$/i, '').trim() - displayName = displayName.replace(/desktop$/i, '').trim() + let displayName = name.charAt(0).toUpperCase() + name.slice(1); + displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2'); + displayName = displayName.replace(/app$/i, '').trim(); + displayName = displayName.replace(/desktop$/i, '').trim(); - return displayName || name + return displayName || name; } function getIcon(icon) { if (!icon) - return "" + return ""; if (icon.startsWith("/") || icon.startsWith("file://")) - return icon - return ThemeIcons.iconFromName(icon) + return icon; + return ThemeIcons.iconFromName(icon); } - function stripTags(text) { - return text.replace(/<[^>]*>?/gm, '') + function escapeHtml(text) { + if (!text) + return ""; + return text.replace(/&/g, "&").replace(//g, ">"); + } + + function sanitizeMarkdownUrl(url) { + if (!url) + return ""; + const trimmed = url.trim(); + if (trimmed === "") + return ""; + const lower = trimmed.toLowerCase(); + if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) { + return encodeURI(trimmed); + } + return ""; + } + + function sanitizeMarkdown(text) { + if (!text) + return ""; + + let input = String(text); + + // Strip images entirely + input = input.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (match, alt) { + return alt ? alt : ""; + }); + + // Extract links into placeholders + const links = []; + input = input.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, label, urlAndTitle) { + const urlPart = (urlAndTitle || "").trim().split(/\s+/)[0] || ""; + const safeUrl = sanitizeMarkdownUrl(urlPart); + const safeLabel = escapeHtml(label); + if (!safeUrl) + return safeLabel; + const token = "__MDLINK_" + links.length + "__"; + links.push({ + "label": safeLabel, + "url": safeUrl + }); + return token; + }); + + // Escape any remaining HTML + input = escapeHtml(input); + + // Restore sanitized links + for (let i = 0; i < links.length; i++) { + const token = "__MDLINK_" + i + "__"; + const link = links[i]; + input = input.split(token).join("[" + link.label + "](" + link.url + ")"); + } + + return input; + } + + function processNotificationText(text) { + if (!text) + return ""; + + // Split by tags to process segments separately + const parts = text.split(/(<[^>]+>)/); + let result = ""; + const allowedTags = ["b", "i", "u", "a", "br"]; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (part.startsWith("<") && part.endsWith(">")) { + const content = part.substring(1, part.length - 1); + const firstWord = content.split(/[\s/]/).filter(s => s.length > 0)[0]?.toLowerCase(); + + if (allowedTags.includes(firstWord)) { + // Preserve valid HTML tag + result += part; + } else { + // Unknown tag: drop tag without leaking attributes + result += ""; + } + } else { + // Normal text: escape everything + result += escapeHtml(part); + } + } + return result; + } + + function processNotificationMarkdown(text) { + return sanitizeMarkdown(text); } function generateImageId(notification, image) { if (image && image.startsWith("image://")) { if (image.startsWith("image://qsimage/")) { - const key = (notification.appName || "") + "|" + (notification.summary || "") - return Checksum.sha256(key) + const key = (notification.appName || "") + "|" + (notification.summary || ""); + return Checksum.sha256(key); } - return Checksum.sha256(image) + return Checksum.sha256(image); + } + return ""; + } + + function pauseTimeout(id) { + const notifData = activeNotifications[id]; + if (notifData && !notifData.metadata.paused) { + notifData.metadata.paused = true; + notifData.metadata.pauseTime = Date.now(); + } + } + + function resumeTimeout(id) { + const notifData = activeNotifications[id]; + if (notifData && notifData.metadata.paused) { + notifData.metadata.timestamp += Date.now() - notifData.metadata.pauseTime; + notifData.metadata.paused = false; } - return "" } // Public API function dismissActiveNotification(id) { - activeMap[id]?.dismiss() - removeActive(id) + userDismissNotification(id); + } + + // User dismissed from active view (e.g. clicked close, or swipe) + // This behaves like "overflow" - removes from active list but KEEPS data for history + function userDismissNotification(id) { + const index = findNotificationIndex(id); + if (index >= 0) { + activeList.remove(index); + } + } + + function dismissOldestActive() { + if (activeList.count > 0) { + const lastNotif = activeList.get(activeList.count - 1); + dismissActiveNotification(lastNotif.id); + } } function dismissAllActive() { - Object.values(activeMap).forEach(n => n.dismiss()) - activeList.clear() - activeMap = {} - notificationMetadata = {} + for (const id in activeNotifications) { + activeNotifications[id].notification?.dismiss(); + activeNotifications[id].watcher?.destroy(); + } + activeList.clear(); + activeNotifications = {}; + quickshellIdToInternalId = {}; + } + + function invokeActionAndSuppressClose(id, actionId) { + const notifData = activeNotifications[id]; + if (notifData && notifData.notification && notifData.onClosed) { + try { + notifData.notification.closed.disconnect(notifData.onClosed); + } catch (e) {} + } + + return invokeAction(id, actionId); } function invokeAction(id, actionId) { - const n = activeMap[id] - if (!n?.actions) - return false + let invoked = false; + const notifData = activeNotifications[id]; - for (const action of n.actions) { - if (action.identifier === actionId && action.invoke) { - action.invoke() - return true + if (notifData && notifData.notification) { + const actionsToUse = (notifData.notification.actions && notifData.notification.actions.length > 0) ? notifData.notification.actions : (notifData.cachedActions || []); + + if (actionsToUse && actionsToUse.length > 0) { + for (const item of actionsToUse) { + const itemId = item.identifier; + const actionObj = item.actionObject ? item.actionObject : item; + + if (itemId === actionId) { + if (actionObj.invoke) { + try { + actionObj.invoke(); + invoked = true; + } catch (e) { + Logger.w("NotificationService", "invoke() failed, trying manual fallback: " + e); + if (manualInvoke(notifData.metadata.originalId, itemId)) { + invoked = true; + } + } + } else { + if (manualInvoke(notifData.metadata.originalId, itemId)) { + invoked = true; + } + } + break; + } + } + } + + if (!invoked && notifData.metadata.originalId) { + Logger.w("NotificationService", "Action objects exhausted, trying manual invoke for id=" + id + " action=" + actionId); + invoked = manualInvoke(notifData.metadata.originalId, actionId); + } + } else if (!notifData) { + Logger.w("NotificationService", "No active notification data for id=" + id + ", searching history for manual invoke"); + for (var i = 0; i < historyList.count; i++) { + if (historyList.get(i).id === id) { + const histEntry = historyList.get(i); + if (histEntry.originalId) { + invoked = manualInvoke(histEntry.originalId, actionId); + } + break; + } } } - return false + + if (!invoked) { + Logger.w("NotificationService", "Failed to invoke action '" + actionId + "' for notification " + id); + return false; + } + + // Clear actions after use + updateModel(activeList, id, "actionsJson", "[]"); + updateModel(historyList, id, "actionsJson", "[]"); + saveHistory(); + + return true; + } + + function manualInvoke(originalId, actionId) { + if (!originalId) { + return false; + } + + try { + // Construct the signal emission using dbus-send + // dbus-send --session --type=signal /org/freedesktop/Notifications org.freedesktop.Notifications.ActionInvoked uint32:ID string:"KEY" + const args = ["dbus-send", "--session", "--type=signal", "/org/freedesktop/Notifications", "org.freedesktop.Notifications.ActionInvoked", "uint32:" + originalId, "string:" + actionId]; + + Quickshell.execDetached(args); + return true; + } catch (e) { + Logger.e("NotificationService", "Manual invoke failed: " + e); + return false; + } + } + + function focusSenderWindow(appName) { + if (!appName || appName === "" || appName === "Unknown") + return false; + + const normalizedName = appName.toLowerCase().replace(/\s+/g, ""); + + for (var i = 0; i < Niri.windows.count; i++) { + const win = Niri.windows.get(i); + const winAppId = (win.appId || "").toLowerCase(); + + const segments = winAppId.split("."); + const lastSegment = segments[segments.length - 1] || ""; + + if (winAppId === normalizedName || lastSegment === normalizedName || winAppId.includes(normalizedName) || normalizedName.includes(lastSegment)) { + Niri.focusWindow(win); + return true; + } + } + + Logger.d("NotificationService", "No window found for app: " + appName); + return false; } function removeFromHistory(notificationId) { for (var i = 0; i < historyList.count; i++) { - const notif = historyList.get(i) + const notif = historyList.get(i); if (notif.id === notificationId) { - if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) { - Quickshell.execDetached(["rm", "-f", notif.cachedImage]) + // Only delete cached images that are in our cache directory + const cachedPath = notif.cachedImage ? notif.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); } - historyList.remove(i) - saveHistory() - return true + historyList.remove(i); + saveHistory(); + return true; } } - return false + return false; + } + + function removeOldestHistory() { + if (historyList.count > 0) { + const oldest = historyList.get(historyList.count - 1); + // Only delete cached images that are in our cache directory + const cachedPath = oldest.cachedImage ? oldest.cachedImage.replace(/^file:\/\//, "") : ""; + if (cachedPath && cachedPath.startsWith(ImageCacheService.notificationsDir)) { + Quickshell.execDetached(["rm", "-f", cachedPath]); + } + historyList.remove(historyList.count - 1); + saveHistory(); + return true; + } + return false; } function clearHistory() { try { - Quickshell.execDetached(["sh", "-c", `rm -rf "${cacheDirImagesNotifications}"*`]) + Quickshell.execDetached(["sh", "-c", `rm -rf "${ImageCacheService.notificationsDir}"*`]); } catch (e) { - Logger.error("Notifications", "Failed to clear cache directory:", e) + Logger.e("Notifications", "Failed to clear cache directory:", e); } - historyList.clear() - saveHistory() + historyList.clear(); + saveHistory(); } - // Signals & connections + function getHistorySnapshot() { + const items = []; + for (var i = 0; i < historyList.count; i++) { + const entry = historyList.get(i); + items.push({ + "id": entry.id, + "summary": entry.summary, + "body": entry.body, + "appName": entry.appName, + "urgency": entry.urgency, + "timestamp": entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp, + "originalImage": entry.originalImage, + "cachedImage": entry.cachedImage + }); + } + return items; + } + + // Signals signal animateAndRemove(string notificationId) - Connections { - target: SettingsService.notifications - function onDoNotDisturbChanged() { - const enabled = SettingsService.notifications.doNotDisturb + // Media toast functionality + property string previousMediaTitle: "" + property string previousMediaArtist: "" + property bool previousMediaIsPlaying: false + property bool mediaToastInitialized: false + + Timer { + id: mediaToastInitTimer + interval: 3000 // Wait 3 seconds after startup to avoid initial toast + running: true + onTriggered: { + root.mediaToastInitialized = true; + root.previousMediaTitle = MediaService.trackTitle; + root.previousMediaArtist = MediaService.trackArtist; + root.previousMediaIsPlaying = MediaService.isPlaying; } } } diff --git a/config/quickshell/.config/quickshell/Services/NukeKded6.qml b/config/quickshell/.config/quickshell/Services/NukeKded6.qml index 7669e3e..9e502f4 100644 --- a/config/quickshell/.config/quickshell/Services/NukeKded6.qml +++ b/config/quickshell/.config/quickshell/Services/NukeKded6.qml @@ -1,7 +1,6 @@ import QtQuick import Quickshell import Quickshell.Io -import qs.Utils pragma Singleton Singleton { @@ -11,11 +10,8 @@ Singleton { id: process running: true - command: ["sh", "-c", "which kquitapp6 && kquitapp6 kded6"] + command: ["sh", "-c", "pgrep -x kded6 && { { type kquitapp6 && kquitapp6 kded6 || killall -9 kded6; }; sleep 0.5; } >/dev/null 2>&1"] onExited: (code, status) => { - if (code !== 0) - Logger.warn("NukeKded6", `Failed to kill kded6: ${code}`); - done = true; } } diff --git a/config/quickshell/.config/quickshell/Services/PanelService.qml b/config/quickshell/.config/quickshell/Services/PanelService.qml index 0506d49..b811ee1 100644 --- a/config/quickshell/.config/quickshell/Services/PanelService.qml +++ b/config/quickshell/.config/quickshell/Services/PanelService.qml @@ -1,67 +1,424 @@ pragma Singleton +import QtQuick import Quickshell +import qs.Utils +import qs.Services Singleton { id: root - // A ref. to the lockScreen, so it's accessible from anywhere - // This is not a panel... + // A ref. to the lockScreen, so it's accessible from anywhere. property var lockScreen: null // Panels property var registeredPanels: ({}) property var openedPanel: null + property var closingPanel: null + property bool closedImmediately: false + + // Overlay launcher state (separate from normal panels) + property bool overlayLauncherOpen: false + property var overlayLauncherScreen: null + property var overlayLauncherCore: null // Reference to LauncherCore when overlay is active + // Brief window after panel opens where Exclusive keyboard is allowed on Hyprland + // This allows text inputs to receive focus, then switches to OnDemand for click-to-close + property bool isInitializingKeyboard: false + + // Global state for keybind recording components to block global shortcuts + property bool isKeybindRecording: false + + property var restrictedMonitors: [] + property bool allowPanelsOnScreenWithoutBar: true + property bool overviewLayer: false + signal willOpen + signal didClose - // Currently opened popups, can have more than one. - // ex: when opening an NIconPicker from a widget setting. - property var openedPopups: [] - property bool hasOpenedPopup: false - signal popupChanged + // Background slot assignments for dynamic panel background rendering + // Slot 0: currently opening/open panel, Slot 1: closing panel + property var backgroundSlotAssignments: [null, null] + signal slotAssignmentChanged(int slotIndex, var panel) - // Register this panel - function registerPanel(panel) { - registeredPanels[panel.objectName] = panel + function assignToSlot(slotIndex, panel) { + if (backgroundSlotAssignments[slotIndex] !== panel) { + var newAssignments = backgroundSlotAssignments.slice(); + newAssignments[slotIndex] = panel; + backgroundSlotAssignments = newAssignments; + slotAssignmentChanged(slotIndex, panel); + } } - // Returns a panel - function getPanel(name) { - return registeredPanels[name] || null + // Popup menu windows (one per screen) - used for both tray menus and context menus + property var popupMenuWindows: ({}) + signal popupMenuWindowRegistered(var screen) + + // Register this panel (called after panel is loaded) + function registerPanel(panel) { + registeredPanels[panel.objectName] = panel; + Logger.d("PanelService", "Registered panel:", panel.objectName); + } + + // Register popup menu window for a screen + function registerPopupMenuWindow(screen, window) { + if (!screen || !window) + return; + var key = screen.name; + popupMenuWindows[key] = window; + Logger.d("PanelService", "Registered popup menu window for screen:", key); + popupMenuWindowRegistered(screen); + } + + // Unregister popup menu window for a screen (called on destruction) + function unregisterPopupMenuWindow(screen) { + if (!screen) + return; + var key = screen.name; + delete popupMenuWindows[key]; + Logger.d("PanelService", "Unregistered popup menu window for screen:", key); + } + + // Get popup menu window for a screen + function getPopupMenuWindow(screen) { + if (!screen) + return null; + return popupMenuWindows[screen.name] || null; + } + + // Show a context menu with proper handling for all compositors + // Optional targetItem: if provided, menu will be horizontally centered on this item instead of anchorItem + function showContextMenu(contextMenu, anchorItem, screen, targetItem) { + if (!contextMenu || !anchorItem) + return; + + // Close any previously opened context menu first + closeContextMenu(screen); + + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow) { + popupMenuWindow.showContextMenu(contextMenu); + contextMenu.openAtItem(anchorItem, screen, targetItem); + } + } + + // Close any open context menu or popup menu window + function closeContextMenu(screen) { + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow && popupMenuWindow.visible) { + popupMenuWindow.close(); + } + } + + // Show a tray menu with proper handling for all compositors + // Returns true if menu was shown successfully + function showTrayMenu(screen, trayItem, trayMenu, anchorItem, menuX, menuY, widgetSection, widgetIndex) { + if (!trayItem || !trayMenu || !anchorItem) + return false; + + // Close any previously opened menu first + closeContextMenu(screen); + + trayMenu.trayItem = trayItem; + trayMenu.widgetSection = widgetSection; + trayMenu.widgetIndex = widgetIndex; + + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow) { + popupMenuWindow.open(); + trayMenu.showAt(anchorItem, menuX, menuY); + } else { + return false; + } + return true; + } + + // Close tray menu + function closeTrayMenu(screen) { + var popupMenuWindow = getPopupMenuWindow(screen); + if (popupMenuWindow) { + // This closes both the window and calls hideMenu on the tray menu + popupMenuWindow.close(); + } + } + + // Find a fallback screen, prioritizing 0x0 position (primary) + function findFallbackScreen() { + let primaryCandidate = null; + let firstScreen = null; + + for (let i = 0; i < Quickshell.screens.length; i++) { + const s = Quickshell.screens[i]; + if (s.x === 0 && s.y === 0) { + primaryCandidate = s; + } + if (!firstScreen) { + firstScreen = s; + } + } + + return primaryCandidate || firstScreen || null; + } + + // Returns a panel (loads it on-demand if not yet loaded) + // By default, if panel not found on screen, tries other screens (favoring 0x0) + // Pass fallback=false to disable this behavior + function getPanel(name, screen, fallback = true) { + if (!screen) { + Logger.d("PanelService", "missing screen for getPanel:", name); + // If no screen specified, return the first matching panel + for (var key in registeredPanels) { + if (key.startsWith(name + "-")) { + return registeredPanels[key]; + } + } + return null; + } + + var panelKey = `${name}-${screen.name}`; + + // Check if panel is already loaded + if (registeredPanels[panelKey]) { + return registeredPanels[panelKey]; + } + + // If fallback enabled, try to find panel on another screen + if (fallback) { + // First try the primary screen (0x0) + var fallbackScreen = findFallbackScreen(); + if (fallbackScreen && fallbackScreen.name !== screen.name) { + var fallbackKey = `${name}-${fallbackScreen.name}`; + if (registeredPanels[fallbackKey]) { + Logger.d("PanelService", "Panel fallback from", screen.name, "to", fallbackScreen.name); + return registeredPanels[fallbackKey]; + } + } + + // Try any other screen + for (var key in registeredPanels) { + if (key.startsWith(name + "-")) { + Logger.d("PanelService", "Panel fallback to first available:", key); + return registeredPanels[key]; + } + } + } + + Logger.w("PanelService", "Panel not found:", panelKey); + return null; } // Check if a panel exists function hasPanel(name) { - return name in registeredPanels + return name in registeredPanels; + } + + // Check if panels can be shown on a given screen (has bar enabled or allowPanelsOnScreenWithoutBar) + function canShowPanelsOnScreen(screen) { + const name = screen?.name || ""; + const monitors = root.restrictedMonitors || []; + const allowPanelsOnScreenWithoutBar = root.allowPanelsOnScreenWithoutBar; + return allowPanelsOnScreenWithoutBar || monitors.length === 0 || monitors.includes(name); + } + + // Find a screen that can show panels + function findScreenForPanels() { + for (let i = 0; i < Quickshell.screens.length; i++) { + if (canShowPanelsOnScreen(Quickshell.screens[i])) { + return Quickshell.screens[i]; + } + } + return null; + } + + // Timer to switch from Exclusive to OnDemand keyboard focus on Hyprland + Timer { + id: keyboardInitTimer + interval: 100 + repeat: false + onTriggered: { + root.isInitializingKeyboard = false; + } } // Helper to keep only one panel open at any time function willOpenPanel(panel) { - if (openedPanel && openedPanel !== panel) { - openedPanel.close() + // Close overlay launcher if open + if (overlayLauncherOpen) { + overlayLauncherOpen = false; + overlayLauncherScreen = null; + } + + if (openedPanel && openedPanel !== panel) { + // Move current panel to closing slot before closing it + closingPanel = openedPanel; + assignToSlot(1, closingPanel); + openedPanel.close(); + } + + // Assign new panel to open slot + openedPanel = panel; + assignToSlot(0, panel); + + // Start keyboard initialization period (for Hyprland workaround) + if (panel && panel.exclusiveKeyboard) { + isInitializingKeyboard = true; + keyboardInitTimer.restart(); } - openedPanel = panel // emit signal - willOpen() + willOpen(); + } + + // Open launcher panel (handles both normal and overlay mode) + function openLauncher(screen) { + if (root.overviewLayer) { + // Close any regular panel first + if (openedPanel) { + closingPanel = openedPanel; + assignToSlot(1, closingPanel); + openedPanel.close(); + openedPanel = null; + } + // Open overlay launcher + overlayLauncherOpen = true; + overlayLauncherScreen = screen; + willOpen(); + } else { + // Normal mode - use the SmartPanel + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.open(); + } + } + + // Toggle launcher panel + function toggleLauncher(screen) { + if (root.overviewLayer) { + if (overlayLauncherOpen && overlayLauncherScreen === screen) { + closeOverlayLauncher(); + } else { + openLauncher(screen); + } + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.toggle(); + } + } + + // Close overlay launcher + function closeOverlayLauncher() { + if (overlayLauncherOpen) { + overlayLauncherOpen = false; + overlayLauncherScreen = null; + didClose(); + } + } + + // Close overlay launcher immediately (for app launches) + function closeOverlayLauncherImmediately() { + if (overlayLauncherOpen) { + closedImmediately = true; + overlayLauncherOpen = false; + overlayLauncherScreen = null; + didClose(); + } + } + + // ==================== Unified Launcher API ==================== + // These methods work for both normal (SmartPanel) and overlay modes + + function isLauncherOpen(screen) { + if (root.overviewLayer) { + return overlayLauncherOpen && overlayLauncherScreen === screen; + } else { + var panel = getPanel("launcherPanel", screen); + return panel ? panel.isPanelOpen : false; + } + } + + function getLauncherSearchText(screen) { + if (root.overviewLayer) { + return overlayLauncherCore ? overlayLauncherCore.searchText : ""; + } else { + var panel = getPanel("launcherPanel", screen); + return panel ? panel.searchText : ""; + } + } + + function setLauncherSearchText(screen, text) { + if (root.overviewLayer) { + if (overlayLauncherCore) + overlayLauncherCore.setSearchText(text); + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.setSearchText(text); + } + } + + function openLauncherWithSearch(screen, searchText) { + if (root.overviewLayer) { + openLauncher(screen); + // Set search text after core is ready + Qt.callLater(() => { + if (overlayLauncherCore) + overlayLauncherCore.setSearchText(searchText); + }); + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) { + panel.open(); + panel.setSearchText(searchText); + } + } + } + + function closeLauncher(screen) { + if (root.overviewLayer) { + closeOverlayLauncher(); + } else { + var panel = getPanel("launcherPanel", screen); + if (panel) + panel.close(); + } + } + + // Close any open panel (for general use) + function closePanel() { + if (overlayLauncherOpen) { + closeOverlayLauncher(); + } else if (openedPanel && openedPanel.close) { + openedPanel.close(); + } } function closedPanel(panel) { if (openedPanel && openedPanel === panel) { - openedPanel = null + openedPanel = null; + assignToSlot(0, null); + } + + if (closingPanel && closingPanel === panel) { + closingPanel = null; + assignToSlot(1, null); + } + + // Reset keyboard init state + isInitializingKeyboard = false; + keyboardInitTimer.stop(); + + // emit signal + didClose(); + } + + // Close panels when compositor overview opens (if setting is enabled) + Connections { + target: Niri + + function onOverviewActiveChanged() { + if (Niri.overviewActive && root.openedPanel) { + root.openedPanel.close(); + } } } - - // Popups - function willOpenPopup(popup) { - openedPopups.push(popup) - hasOpenedPopup = (openedPopups.length !== 0) - popupChanged() - } - - function willClosePopup(popup) { - openedPopups = openedPopups.filter(p => p !== popup) - hasOpenedPopup = (openedPopups.length !== 0) - popupChanged() - } } diff --git a/config/quickshell/.config/quickshell/Services/PowerProfileService.qml b/config/quickshell/.config/quickshell/Services/PowerService.qml similarity index 77% rename from config/quickshell/.config/quickshell/Services/PowerProfileService.qml rename to config/quickshell/.config/quickshell/Services/PowerService.qml index 422a1d6..ae58b5d 100644 --- a/config/quickshell/.config/quickshell/Services/PowerProfileService.qml +++ b/config/quickshell/.config/quickshell/Services/PowerService.qml @@ -53,7 +53,7 @@ Singleton { try { powerProfiles.profile = p; } catch (e) { - Logger.error("PowerProfileService", "Failed to set profile:", e); + Logger.e("PowerProfileService", "Failed to set profile:", e); } } @@ -70,16 +70,35 @@ Singleton { setProfile(PowerProfile.Balanced); } + function shutdown() { + Quickshell.execDetached(["systemctl", "poweroff"]); + } + + function lock() { + Quickshell.execDetached(["hyprlock"]); + } + + function hibernate() { + Quickshell.execDetached(["systemctl", "hibernate"]); + } + + function logout() { + Quickshell.execDetached(["niri", "msg", "action", "quit"]); + } + + function suspend() { + Quickshell.execDetached(["systemctl", "suspend"]); + } + + function reboot() { + Quickshell.execDetached(["systemctl", "reboot"]); + } + Connections { function onProfileChanged() { root.profile = powerProfiles.profile; // Only show toast if we have a valid profile name (not "Unknown") const profileName = root.getName(); - if (profileName !== "Unknown") - ToastService.showNotice(I18n.tr("toast.power-profile.changed"), I18n.tr("toast.power-profile.profile-name", { - "profile": profileName - })); - } target: powerProfiles diff --git a/config/quickshell/.config/quickshell/Services/RecordService.qml b/config/quickshell/.config/quickshell/Services/RecordService.qml index 7f9ea03..d54d09f 100644 --- a/config/quickshell/.config/quickshell/Services/RecordService.qml +++ b/config/quickshell/.config/quickshell/Services/RecordService.qml @@ -1,12 +1,13 @@ import QtQuick import Quickshell import Quickshell.Io +import qs.Constants import qs.Services import qs.Utils pragma Singleton Singleton { - readonly property string recordingDir: CacheService.recordingDir + readonly property string recordingDir: Paths.recordingDir property bool isRecording: false property bool isStopping: false readonly property string codec: "libx264" @@ -14,10 +15,7 @@ Singleton { readonly property string pixelFormat: "yuv420p" property string recordingDisplay: "" readonly property int framerate: 60 - readonly property var codecParams: Object.freeze([ - "preset=ultrafast", "crf=15", "tune=zerolatency", - "color_range=tv" - ]) + readonly property var codecParams: Object.freeze(["preset=ultrafast", "crf=15", "tune=zerolatency", "color_range=tv"]) readonly property var filterArgs: "" function getFilename() { @@ -36,12 +34,7 @@ Singleton { } function getVideoSource(callback) { - if (niriFocusedOutputProcess.running) { - Logger.warn("RecordService", "Already fetching focused output, returning null."); - callback(null); - } - niriFocusedOutputProcess.onGetName = callback; - niriFocusedOutputProcess.running = true; + return Niri.focusedOutput || null; } function startOrStop() { @@ -53,11 +46,11 @@ Singleton { function stop() { if (!isRecording) { - Logger.warn("RecordService", "Not currently recording, cannot stop."); + Logger.w("RecordService", "Not currently recording, cannot stop."); return ; } if (isStopping) { - Logger.warn("RecordService", "Already stopping, please wait."); + Logger.w("RecordService", "Already stopping, please wait."); return ; } isStopping = true; @@ -66,41 +59,44 @@ Singleton { function start() { if (isRecording || isStopping) { - Logger.warn("RecordService", "Already recording, cannot start."); + Logger.w("RecordService", "Already recording, cannot start."); return ; } isRecording = true; - getVideoSource((source) => { - if (!source) { - SendNotification.show("Recording failed", "Could not determine which display to record from."); - return ; - } - recordingDisplay = source; - const audioSink = getAudioSink(); - if (!audioSink) { - SendNotification.show("Recording failed", "No audio sink available to record from."); - return ; - } - recordProcess.filePath = recordingDir + getFilename(); - recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath]; - for (const param of codecParams) { - recordProcess.command.push("-p"); - recordProcess.command.push(param); - } - if (filterArgs !== "") { - recordProcess.command.push("-F"); - recordProcess.command.push(filterArgs); - } - Logger.log("RecordService", "Starting recording with command: " + recordProcess.command.join(" ")); - recordProcess.onErrorExit = function() { - SendNotification.show("Recording failed", "An error occurred while trying to record the screen."); - }; - recordProcess.onNormalExit = function() { - SendNotification.show("Recording stopped", recordProcess.filePath); - }; - recordProcess.running = true; - SendNotification.show("Recording started", "Recording to " + recordProcess.filePath); - }); + const source = getVideoSource(); + if (!source) { + SendNotification.show("Recording failed", "Could not determine which display to record from."); + Logger.e("RecordService", "No recording source available."); + return ; + } + recordingDisplay = source; + const audioSink = getAudioSink(); + if (!audioSink) { + SendNotification.show("Recording failed", "No audio sink available to record from."); + Logger.e("RecordService", "No audio sink available."); + return ; + } + recordProcess.filePath = recordingDir + getFilename(); + recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath]; + for (const param of codecParams) { + recordProcess.command.push("-p"); + recordProcess.command.push(param); + } + if (filterArgs !== "") { + recordProcess.command.push("-F"); + recordProcess.command.push(filterArgs); + } + Logger.i("RecordService", "Starting recording with command: " + recordProcess.command.join(" ")); + recordProcess.onErrorExit = function() { + Logger.e("RecordService", "Recording process exited with an error."); + SendNotification.show("Recording failed", "An error occurred while trying to record the screen."); + }; + recordProcess.onNormalExit = function() { + Logger.i("RecordService", "Recording stopped, file saved to: " + recordProcess.filePath); + SendNotification.show("Recording stopped", recordProcess.filePath); + }; + recordProcess.running = true; + SendNotification.show("Recording started", "Recording to " + recordProcess.filePath); } Process { @@ -113,13 +109,13 @@ Singleton { running: false onExited: function(exitCode, exitStatus) { if (exitCode === 0) { - Logger.log("RecordService", "Recording stopped successfully."); + Logger.i("RecordService", "Recording stopped successfully."); if (onNormalExit) { onNormalExit(); onNormalExit = null; } } else { - Logger.error("RecordService", "Recording process exited with error code: " + exitCode); + Logger.e("RecordService", "Recording process exited with error code: " + exitCode); if (onErrorExit) { onErrorExit(); onErrorExit = null; @@ -131,36 +127,4 @@ Singleton { } } - Process { - id: niriFocusedOutputProcess - - property var onGetName: null - - running: false - command: ["niri", "msg", "focused-output"] - onExited: function(exitCode, exitStatus) { - if (exitCode !== 0) { - Logger.error("RecordService", "Failed to get focused output via niri."); - if (niriFocusedOutputProcess.onGetName) { - niriFocusedOutputProcess.onGetName(null); - niriFocusedOutputProcess.onGetName = null; - } - } - } - - stdout: SplitParser { - splitMarker: "\n" - onRead: (data) => { - if (niriFocusedOutputProcess.onGetName) { - const parts = data.split(' '); - const name = parts.length > 0 ? parts[parts.length - 1].slice(1)?.slice(0, -1) : null; - name ? Logger.log("RecordService", "Focused output is: " + name) : Logger.warn("RecordService", "No focused output found."); - niriFocusedOutputProcess.onGetName(name); - niriFocusedOutputProcess.onGetName = null; - } - } - } - - } - } diff --git a/config/quickshell/.config/quickshell/Services/Screenshot.qml b/config/quickshell/.config/quickshell/Services/Screenshot.qml deleted file mode 100644 index 81351e4..0000000 --- a/config/quickshell/.config/quickshell/Services/Screenshot.qml +++ /dev/null @@ -1,16 +0,0 @@ -import QtQuick -import Quickshell -import Quickshell.Io -pragma Singleton - -Singleton { - id: root - - function onScreenshotCaptured(path) { - if (!path || typeof path !== "string") - return ; - - Quickshell.execDetached(["screenshot-script", "edit", path]); - } - -} diff --git a/config/quickshell/.config/quickshell/Services/SettingsService.qml b/config/quickshell/.config/quickshell/Services/SettingsService.qml index fb7f18d..9b44df1 100644 --- a/config/quickshell/.config/quickshell/Services/SettingsService.qml +++ b/config/quickshell/.config/quickshell/Services/SettingsService.qml @@ -2,40 +2,38 @@ import QtQuick import Quickshell import Quickshell.Io import qs.Constants -import qs.Services pragma Singleton Singleton { - property alias primaryColor: adapter.primaryColor - property alias showLyricsBar: adapter.showLyricsBar - property alias notifications: adapter.notifications + id: root + + property string settingsFilePath: Paths.configDir + "settings.json" + property alias geoInfoToken: adapter.geoInfoToken + property alias ipAliases: adapter.ipAliases property alias location: adapter.location + property alias backgroundPath: adapter.backgroundPath property alias wifiEnabled: adapter.wifiEnabled - property alias sunsetDefaultEnabled: adapter.sunsetDefaultEnabled - property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json") FileView { - id: settingsFile + id: settingFile path: settingsFilePath watchChanges: true - onFileChanged: reload() + onFileChanged: { + reload(); + } onAdapterUpdated: writeAdapter() JsonAdapter { id: adapter - property string primaryColor: "#89b4fa" - property bool showLyricsBar: false - property JsonObject notifications - property string location: "New York" - property bool wifiEnabled: true - property bool sunsetDefaultEnabled: true - - notifications: JsonObject { - property bool doNotDisturb: false + property string geoInfoToken: "" + property var ipAliases: { + "127.0.0.1": "localhost" } - + property string location: "New York" + property string backgroundPath: "" + property bool wifiEnabled: true } } diff --git a/config/quickshell/.config/quickshell/Services/ShellState.qml b/config/quickshell/.config/quickshell/Services/ShellState.qml new file mode 100644 index 0000000..344265d --- /dev/null +++ b/config/quickshell/.config/quickshell/Services/ShellState.qml @@ -0,0 +1,72 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Constants +import qs.Utils +pragma Singleton + +Singleton { + id: root + + property string stateFile: Paths.cacheDir + "shell-state.json" + property bool isLoaded: false + property alias notificationsState: adapter.notificationsState + property alias lyricsState: adapter.lyricsState + property alias sunsetState: adapter.sunsetState + + function save() { + saveTimer.restart(); + } + + onNotificationsStateChanged: save() + onLyricsStateChanged: save() + onSunsetStateChanged: save() + Component.onCompleted: { + stateFileView.path = stateFile; + } + + FileView { + id: stateFileView + + printErrors: false + watchChanges: false + onLoaded: { + root.isLoaded = true; + Logger.d("ShellState", "Loaded state file"); + } + onLoadFailed: (error) => { + root.isLoaded = true; + } + + adapter: JsonAdapter { + id: adapter + + property var notificationsState: ({ + "lastSeenTs": 0, + "doNotDisturb": false + }) + property var lyricsState: ({ + "showLyricsBar": false + }) + property var sunsetState: ({ + "enabled": true + }) + } + + } + + Timer { + id: saveTimer + + interval: 500 + onTriggered: { + if (stateFile) { + try { + stateFileView.writeAdapter(); + } catch (e) { + } + } + } + } + +} diff --git a/config/quickshell/.config/quickshell/Services/SunsetService.qml b/config/quickshell/.config/quickshell/Services/SunsetService.qml index f4c4d9d..6348ba2 100644 --- a/config/quickshell/.config/quickshell/Services/SunsetService.qml +++ b/config/quickshell/.config/quickshell/Services/SunsetService.qml @@ -8,64 +8,71 @@ pragma Singleton Singleton { id: root - property bool defaultRunning: SettingsService.sunsetDefaultEnabled property double _latitude: -1 property double _longitude: -1 - property alias isRunning: sunsetProcess.running property int temperature: 0 - - function startSunset() { - if (isRunning) - return ; - - if (_latitude == -1 || _longitude == -1) { - Logger.warn("Sunset", "Cannot start sunset process, invalid coordinates"); - return ; - } - sunsetProcess.command = ["wlsunset", "-l", _latitude.toString(), "-L", _longitude.toString()]; - sunsetProcess.running = true; - } - - function stopSunset() { - if (!isRunning) - return ; - - sunsetProcess.running = false; - } + property bool isEnabled: ShellState.sunsetState.enabled || false function toggleSunset() { - if (isRunning) - stopSunset(); - else - startSunset(); + ShellState.sunsetState = { + "enabled": !root.isEnabled + }; } function setLat(lat) { _latitude = lat; - Logger.log("Sunset", "Updated latitude to " + lat); + Logger.i("Sunset", "Updated latitude to " + lat); checkStart(); } function setLong(lng) { _longitude = lng; - Logger.log("Sunset", "Updated longitude to " + lng); + Logger.i("Sunset", "Updated longitude to " + lng); checkStart(); } function checkStart() { - if (_latitude != -1 && _longitude != -1 && defaultRunning && !isRunning) - startSunset(); - + if (_latitude !== -1 && _longitude !== -1 && root.isEnabled) { + sunsetProcess.command = ["wlsunset", "-l", _latitude.toString(), "-L", _longitude.toString()]; + sunsetProcess.running = true; + } } Connections { - target: LocationService.data - onLatitudeChanged: { + function onLatitudeChanged() { + Logger.d(""); setLat(LocationService.data.latitude); } - onLongitudeChanged: { + + function onLongitudeChanged() { setLong(LocationService.data.longitude); } + + target: LocationService.data + } + + Connections { + function onIsEnabledChanged() { + if (root.isEnabled) + checkStart(); + else + sunsetProcess.running = false; + } + + target: root + } + + Connections { + function onRunningChanged() { + if (!sunsetProcess.running) { + temperature = 0; + Logger.i("Sunset", "Stopped sunset process"); + } else { + Logger.i("Sunset", "Started sunset process"); + } + } + + target: sunsetProcess } Process { @@ -80,15 +87,11 @@ Singleton { var tempMatch = line.match(/setting temperature to (\d+) K/); if (tempMatch && tempMatch.length == 2) { temperature = parseInt(tempMatch[1]); - Logger.log("Sunset", "Updated temperature to " + temperature + " K"); + Logger.d("Sunset", "Updated temperature to " + temperature + " K"); } } } } - NetworkFetch { - id: curl - } - } diff --git a/config/quickshell/.config/quickshell/Services/SystemStatService.qml b/config/quickshell/.config/quickshell/Services/SystemStatService.qml index bc571df..0a9e3c3 100644 --- a/config/quickshell/.config/quickshell/Services/SystemStatService.qml +++ b/config/quickshell/.config/quickshell/Services/SystemStatService.qml @@ -1,392 +1,861 @@ +pragma Singleton import Qt.labs.folderlistmodel + import QtQuick import Quickshell import Quickshell.Io import qs.Utils -pragma Singleton +import qs.Constants Singleton { - // For Intel coretemp, start averaging all available sensors/cores + id: root - id: root + // Component registration - only poll when something needs system stat data + function registerComponent(componentId) { + root._registered[componentId] = true; + root._registered = Object.assign({}, root._registered); + Logger.d("SystemStat", "Component registered:", componentId, "- total:", root._registeredCount); + } - // Public values - property real cpuUsage: 0 - property real cpuTemp: 0 - property real memGb: 0 - property real memPercent: 0 - property real diskPercent: 0 - property real rxSpeed: 0 - property real txSpeed: 0 - // Configuration - property int sleepDuration: 3000 - property int fasterSleepDuration: 1000 - // Internal state for CPU calculation - property var prevCpuStats: null - // Internal state for network speed calculation - // Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered - // since the computer started, so their value will easily overlfow a 32bit int. - property real prevRxBytes: 0 - property real prevTxBytes: 0 - property real prevTime: 0 - // Cpu temperature is the most complex - readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"] - property string cpuTempSensorName: "" - property string cpuTempHwmonPath: "" - // For Intel coretemp averaging of all cores/sensors - property var intelTempValues: [] - property int intelTempFilesChecked: 0 - property int intelTempMaxFiles: 20 // Will test up to temp20_input + function unregisterComponent(componentId) { + delete root._registered[componentId]; + root._registered = Object.assign({}, root._registered); + Logger.d("SystemStat", "Component unregistered:", componentId, "- total:", root._registeredCount); + } - // ------------------------------------------------------- - // ------------------------------------------------------- - // Parse memory info from /proc/meminfo - function parseMemoryInfo(text) { - if (!text) - return ; + property var _registered: ({}) + readonly property int _registeredCount: Object.keys(_registered).length + readonly property bool _lockScreenActive: PanelService.lockScreen?.active ?? false + readonly property bool shouldRun: _registeredCount > 0 && !_lockScreenActive - const lines = text.split('\n'); - let memTotal = 0; - let memAvailable = 0; - for (const line of lines) { - if (line.startsWith('MemTotal:')) - memTotal = parseInt(line.split(/\s+/)[1]) || 0; - else if (line.startsWith('MemAvailable:')) - memAvailable = parseInt(line.split(/\s+/)[1]) || 0; + // Polling intervals (hardcoded to sensible values per stat type) + readonly property int cpuUsageIntervalMs: 1000 + readonly property int cpuFreqIntervalMs: 3000 + readonly property int memIntervalMs: 5000 + readonly property int networkIntervalMs: 3000 + readonly property int loadAvgIntervalMs: 10000 + + // Public values + property real cpuUsage: 0 + property real cpuTemp: 0 + property string cpuFreq: "0.0GHz" + property real cpuFreqRatio: 0 + property real cpuGlobalMaxFreq: 3.5 + property real memGb: 0 + property real memPercent: 0 + property real memTotalGb: 0 + property real rxSpeed: 0 + property real txSpeed: 0 + property real zfsArcSizeKb: 0 // ZFS ARC cache size in KB + property real zfsArcCminKb: 0 // ZFS ARC minimum (non-reclaimable) size in KB + property real loadAvg1: 0 + property real loadAvg5: 0 + property real loadAvg15: 0 + property int nproc: 0 // Number of cpu cores + + // History arrays (1 minute of data, length computed from polling interval) + // Pre-filled with zeros so the graph scrolls smoothly from the start + readonly property int historyDurationMs: (1 * 60 * 1000) // 1 minute + + // Computed history lengths based on polling intervals + readonly property int cpuHistoryLength: Math.ceil(historyDurationMs / cpuUsageIntervalMs) + readonly property int memHistoryLength: Math.ceil(historyDurationMs / memIntervalMs) + readonly property int networkHistoryLength: Math.ceil(historyDurationMs / networkIntervalMs) + + property var cpuHistory: new Array(cpuHistoryLength).fill(0) + property var cpuTempHistory: new Array(cpuHistoryLength).fill(40) // Reasonable default temp + property var memHistory: new Array(memHistoryLength).fill(0) + property var rxSpeedHistory: new Array(networkHistoryLength).fill(0) + property var txSpeedHistory: new Array(networkHistoryLength).fill(0) + + // Historical min/max tracking (since shell started) for consistent graph scaling + // Temperature defaults create a valid 30-80°C range that expands as real data comes in + property real cpuTempHistoryMin: 30 + property real cpuTempHistoryMax: 80 + // Network uses autoscaling from current history window + + // History management - called from update functions, not change handlers + // (change handlers don't fire when value stays the same) + function pushCpuHistory() { + let h = cpuHistory.slice(); + h.push(cpuUsage); + if (h.length > cpuHistoryLength) + h.shift(); + cpuHistory = h; + } + + function pushCpuTempHistory() { + if (cpuTemp > 0) { + if (cpuTemp < cpuTempHistoryMin) + cpuTempHistoryMin = cpuTemp; + if (cpuTemp > cpuTempHistoryMax) + cpuTempHistoryMax = cpuTemp; + } + let h = cpuTempHistory.slice(); + h.push(cpuTemp); + if (h.length > cpuHistoryLength) + h.shift(); + cpuTempHistory = h; + } + + function pushMemHistory() { + let h = memHistory.slice(); + h.push(memPercent); + if (h.length > memHistoryLength) + h.shift(); + memHistory = h; + } + + function pushNetworkHistory() { + let rxH = rxSpeedHistory.slice(); + rxH.push(rxSpeed); + if (rxH.length > networkHistoryLength) + rxH.shift(); + rxSpeedHistory = rxH; + + let txH = txSpeedHistory.slice(); + txH.push(txSpeed); + if (txH.length > networkHistoryLength) + txH.shift(); + txSpeedHistory = txH; + } + + // Network max speed tracking (autoscales from current history window) + // Minimum floor of 1 MB/s so graph doesn't fluctuate at low speeds + readonly property real rxMaxSpeed: { + const max = Math.max(...rxSpeedHistory); + return Math.max(max, 1000000); // 1 MB/s floor + } + readonly property real txMaxSpeed: { + const max = Math.max(...txSpeedHistory); + return Math.max(max, 512000); // 512 KB/s floor + } + + // Ready-to-use ratios based on current maximums (0..1 range) + readonly property real rxRatio: rxMaxSpeed > 0 ? Math.min(1, rxSpeed / rxMaxSpeed) : 0 + readonly property real txRatio: txMaxSpeed > 0 ? Math.min(1, txSpeed / txMaxSpeed) : 0 + + // Internal state for CPU calculation + property var prevCpuStats: null + + // Internal state for network speed calculation + // Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered + // since the computer started, so their value will easily overlfow a 32bit int. + property real prevRxBytes: 0 + property real prevTxBytes: 0 + property real prevTime: 0 + + // Cpu temperature is the most complex + readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"] + property string cpuTempSensorName: "" + property string cpuTempHwmonPath: "" + // For Intel coretemp averaging of all cores/sensors + property var intelTempValues: [] + property int intelTempFilesChecked: 0 + property int intelTempMaxFiles: 20 // Will test up to temp20_input + + // Thermal zone fallback (for ARM SoCs with SCMI sensors, etc.) + // Matches thermal zone types containing "cpu" and picks the hottest big-core zone. + readonly property var thermalZoneCpuPatterns: ["cpu-b", "cpu-m", "cpu"] + property string cpuThermalZonePath: "" + property var cpuThermalZonePaths: [] // All matching CPU zones for averaging + + // -------------------------------------------- + Component.onCompleted: { + Logger.i("SystemStat", "Service started (polling deferred until a consumer registers)."); + + // Kickoff the cpu name detection for temperature (one-time probes, not polling) + cpuTempNameReader.checkNext(); + + // Get nproc on startup (one-time) + nprocProcess.running = true; + } + + onShouldRunChanged: { + if (shouldRun) { + // Reset differential state so first readings after resume are clean + root.prevCpuStats = null; + root.prevTime = 0; + + // Trigger initial reads + zfsArcStatsFile.reload(); + loadAvgFile.reload(); + } + } + + // Reset differential state after suspend so the first reading is treated as fresh + Connections { + target: Time + function onResumed() { + Logger.i("SystemStat", "System resumed - resetting differential state"); + root.prevCpuStats = null; + root.prevTime = 0; + } + } + + // -------------------------------------------- + // Timer for CPU usage and temperature + Timer { + id: cpuTimer + interval: root.cpuUsageIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: { + cpuStatFile.reload(); + updateCpuTemperature(); + } + } + + // Timer for CPU frequency (slower — /proc/cpuinfo is large and freq changes infrequently) + Timer { + id: cpuFreqTimer + interval: root.cpuFreqIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: cpuInfoFile.reload() + } + + // Timer for load average + Timer { + id: loadAvgTimer + interval: root.loadAvgIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: loadAvgFile.reload() + } + + // Timer for memory stats + Timer { + id: memoryTimer + interval: root.memIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: { + memInfoFile.reload(); + zfsArcStatsFile.reload(); + } + } + + // Timer for network speeds + Timer { + id: networkTimer + interval: root.networkIntervalMs + repeat: true + running: root.shouldRun + triggeredOnStart: true + onTriggered: netDevFile.reload() + } + + // -------------------------------------------- + // FileView components for reading system files + FileView { + id: memInfoFile + path: "/proc/meminfo" + onLoaded: parseMemoryInfo(text()) + } + + FileView { + id: cpuStatFile + path: "/proc/stat" + onLoaded: calculateCpuUsage(text()) + } + + FileView { + id: netDevFile + path: "/proc/net/dev" + onLoaded: calculateNetworkSpeed(text()) + } + + FileView { + id: loadAvgFile + path: "/proc/loadavg" + onLoaded: parseLoadAverage(text()) + } + + // ZFS ARC stats file (only exists on ZFS systems) + FileView { + id: zfsArcStatsFile + path: "/proc/spl/kstat/zfs/arcstats" + printErrors: false + onLoaded: parseZfsArcStats(text()) + onLoadFailed: { + // File doesn't exist (non-ZFS system), set ARC values to 0 + root.zfsArcSizeKb = 0; + root.zfsArcCminKb = 0; + } + } + + // Process to get number of processors + Process { + id: nprocProcess + command: ["nproc"] + running: false + stdout: StdioCollector { + onStreamFinished: { + root.nproc = parseInt(text.trim()); + } + } + } + + // FileView to get avg cpu frequency (replaces subprocess spawn of `cat /proc/cpuinfo`) + FileView { + id: cpuInfoFile + path: "/proc/cpuinfo" + onLoaded: { + let txt = text(); + let matches = txt.match(/cpu MHz\s+:\s+([0-9.]+)/g); + if (matches && matches.length > 0) { + let totalFreq = 0.0; + for (let i = 0; i < matches.length; i++) { + totalFreq += parseFloat(matches[i].split(":")[1]); } - if (memTotal > 0) { - const usageKb = memTotal - memAvailable; - root.memGb = (usageKb / 1e+06).toFixed(1); - root.memPercent = Math.round((usageKb / memTotal) * 100); + let avgFreq = (totalFreq / matches.length) / 1000.0; + root.cpuFreq = avgFreq.toFixed(1) + "GHz"; + cpuMaxFreqFile.reload(); + if (avgFreq > root.cpuGlobalMaxFreq) + root.cpuGlobalMaxFreq = avgFreq; + if (root.cpuGlobalMaxFreq > 0) { + root.cpuFreqRatio = Math.min(1.0, avgFreq / root.cpuGlobalMaxFreq); } + } + } + } + + // FileView to get maximum CPU frequency limit (replaces subprocess spawn) + // Reads cpu0's scaling_max_freq as representative value + FileView { + id: cpuMaxFreqFile + path: "/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq" + printErrors: false + onLoaded: { + let maxKHz = parseInt(text().trim()); + if (!isNaN(maxKHz) && maxKHz > 0) { + let newMaxFreq = maxKHz / 1000000.0; + if (Math.abs(root.cpuGlobalMaxFreq - newMaxFreq) > 0.01) { + root.cpuGlobalMaxFreq = newMaxFreq; + } + } + } + } + + // -------------------------------------------- + // CPU Temperature + // It's more complex. + // ---- + // #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower" + FileView { + id: cpuTempNameReader + property int currentIndex: 0 + printErrors: false + + function checkNext() { + if (currentIndex >= 16) { + // No hwmon sensor found, try thermal_zone fallback (ARM SoCs, SCMI, etc.) + thermalZoneScanner.startScan(); + return; + } + + cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`; + cpuTempNameReader.reload(); } - // ------------------------------------------------------- - // Calculate CPU usage from /proc/stat - function calculateCpuUsage(text) { - if (!text) - return ; - - const lines = text.split('\n'); - const cpuLine = lines[0]; - // First line is total CPU - if (!cpuLine.startsWith('cpu ')) - return ; - - const parts = cpuLine.split(/\s+/); - const stats = { - "user": parseInt(parts[1]) || 0, - "nice": parseInt(parts[2]) || 0, - "system": parseInt(parts[3]) || 0, - "idle": parseInt(parts[4]) || 0, - "iowait": parseInt(parts[5]) || 0, - "irq": parseInt(parts[6]) || 0, - "softirq": parseInt(parts[7]) || 0, - "steal": parseInt(parts[8]) || 0, - "guest": parseInt(parts[9]) || 0, - "guestNice": parseInt(parts[10]) || 0 - }; - const totalIdle = stats.idle + stats.iowait; - const total = Object.values(stats).reduce((sum, val) => { - return sum + val; - }, 0); - if (root.prevCpuStats) { - const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait; - const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => { - return sum + val; - }, 0); - const diffTotal = total - prevTotal; - const diffIdle = totalIdle - prevTotalIdle; - if (diffTotal > 0) - root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1); - - } - root.prevCpuStats = stats; + onLoaded: { + const name = text().trim(); + if (root.supportedTempCpuSensorNames.includes(name)) { + root.cpuTempSensorName = name; + root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`; + Logger.i("SystemStat", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`); + } else { + currentIndex++; + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNext(); + }); + } } - // ------------------------------------------------------- - // Calculate RX and TX speed from /proc/net/dev - // Average speed of all interfaces excepted 'lo' - function calculateNetworkSpeed(text) { - if (!text) - return ; + onLoadFailed: function (error) { + currentIndex++; + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNext(); + }); + } + } - const currentTime = Date.now() / 1000; - const lines = text.split('\n'); - let totalRx = 0; - let totalTx = 0; - for (var i = 2; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) - continue; + // ---- + // #2 - Read sensor value + FileView { + id: cpuTempReader + printErrors: false - const colonIndex = line.indexOf(':'); - if (colonIndex === -1) - continue; + onLoaded: { + const data = text().trim(); + if (root.cpuTempSensorName === "coretemp") { + // For Intel, collect all temperature values + const temp = parseInt(data) / 1000.0; + root.intelTempValues.push(temp); + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNextIntelTemp(); + }); + } else { + // For AMD sensors (k10temp and zenpower), directly set the temperature + root.cpuTemp = Math.round(parseInt(data) / 1000.0); + root.pushCpuTempHistory(); + } + } + onLoadFailed: function (error) { + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNextIntelTemp(); + }); + } + } - const iface = line.substring(0, colonIndex).trim(); - if (iface === 'lo') - continue; + // -------------------------------------------- + // Thermal zone fallback for CPU temperature + // Used on ARM SoCs (e.g., SCMI sensors) where hwmon doesn't expose + // coretemp/k10temp/zenpower. Scans /sys/class/thermal/thermal_zoneN/type + // for CPU zone names, then reads temp from all matching zones. + // + // CPU: reads all cpu-*-thermal zones and reports the hottest core. - const statsLine = line.substring(colonIndex + 1).trim(); - const stats = statsLine.split(/\s+/); - const rxBytes = parseInt(stats[0], 10) || 0; - const txBytes = parseInt(stats[8], 10) || 0; - totalRx += rxBytes; - totalTx += txBytes; - } - // Compute only if we have a previous run to compare to. - if (root.prevTime > 0) { - const timeDiff = currentTime - root.prevTime; - // Avoid division by zero if time hasn't passed. - if (timeDiff > 0) { - let rxDiff = totalRx - root.prevRxBytes; - let txDiff = totalTx - root.prevTxBytes; - // Handle counter resets (e.g., WiFi reconnect), which would cause a negative value. - if (rxDiff < 0) - rxDiff = 0; + FileView { + id: thermalZoneScanner + property int currentIndex: 0 + property var cpuZones: [] + printErrors: false - if (txDiff < 0) - txDiff = 0; - - root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s - root.txSpeed = Math.round(txDiff / timeDiff); - } - } - root.prevRxBytes = totalRx; - root.prevTxBytes = totalTx; - root.prevTime = currentTime; + function startScan() { + currentIndex = 0; + cpuZones = []; + checkNext(); } - // ------------------------------------------------------- - // Helper function to format network speeds - function formatSpeed(bytesPerSecond) { - if (bytesPerSecond < 1024 * 1024) { - const kb = bytesPerSecond / 1024; - if (kb < 10) - return kb.toFixed(1) + "KB"; - else - return Math.round(kb) + "KB"; - } else if (bytesPerSecond < 1024 * 1024 * 1024) { - return (bytesPerSecond / (1024 * 1024)).toFixed(1) + "MB"; - } else { - return (bytesPerSecond / (1024 * 1024 * 1024)).toFixed(1) + "GB"; - } + function checkNext() { + if (currentIndex >= 20) { + finishScan(); + return; + } + thermalZoneScanner.path = `/sys/class/thermal/thermal_zone${currentIndex}/type`; + thermalZoneScanner.reload(); } - // ------------------------------------------------------- - // Compact speed formatter for vertical bar display - function formatCompactSpeed(bytesPerSecond) { - if (!bytesPerSecond || bytesPerSecond <= 0) - return "0"; - - const units = ["", "K", "M", "G"]; - let value = bytesPerSecond; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value = value / 1024; - unitIndex++; - } - // Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded) - if (unitIndex < units.length - 1 && value >= 100) { - value = value / 1024; - unitIndex++; - } - const display = Math.round(value).toString(); - return display + units[unitIndex]; + onLoaded: { + const name = text().trim(); + const zonePath = `/sys/class/thermal/thermal_zone${currentIndex}`; + if (name.startsWith("cpu") && name.endsWith("thermal")) { + cpuZones.push({ + "type": name, + "path": zonePath + "/temp" + }); + } + currentIndex++; + Qt.callLater(() => { + checkNext(); + }); } - // ------------------------------------------------------- - // Function to start fetching and computing the cpu temperature - function updateCpuTemperature() { - // For AMD sensors (k10temp and zenpower), only use Tctl sensor - // temp1_input corresponds to Tctl (Temperature Control) on these sensors - if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") { - cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`; - cpuTempReader.reload(); - } else if (root.cpuTempSensorName === "coretemp") { - root.intelTempValues = []; - root.intelTempFilesChecked = 0; - checkNextIntelTemp(); - } + onLoadFailed: function (error) { + currentIndex++; + Qt.callLater(() => { + checkNext(); + }); } - // ------------------------------------------------------- - // Function to check next Intel temperature sensor - function checkNextIntelTemp() { - if (root.intelTempFilesChecked >= root.intelTempMaxFiles) { - // Calculate average of all found temperatures - if (root.intelTempValues.length > 0) { - let sum = 0; - for (var i = 0; i < root.intelTempValues.length; i++) { - sum += root.intelTempValues[i]; - } - root.cpuTemp = Math.round(sum / root.intelTempValues.length); - } else { - Logger.warn("SystemStatService", "No temperature sensors found for coretemp"); - root.cpuTemp = 0; - } - return ; - } - // Check next temperature file - root.intelTempFilesChecked++; - cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`; - cpuTempReader.reload(); + function finishScan() { + if (cpuZones.length > 0) { + root.cpuTempSensorName = "thermal_zone"; + root.cpuThermalZonePaths = cpuZones.map(z => z.path); + const types = cpuZones.map(z => z.type).join(", "); + Logger.i("SystemStat", `Found ${cpuZones.length} CPU thermal zone(s): ${types}`); + } else if (root.cpuTempHwmonPath === "") { + Logger.w("SystemStat", "No supported temperature sensor found"); + } + } + } + + // Thermal zone reader for CPU: reads all zones, reports max (hottest core) + FileView { + id: cpuThermalZoneReader + property int currentZoneIndex: 0 + property var collectedTemps: [] + printErrors: false + + onLoaded: { + const temp = parseInt(text().trim()) / 1000.0; + if (!isNaN(temp) && temp > 0) + collectedTemps.push(temp); + currentZoneIndex++; + Qt.callLater(() => { + readNextCpuThermalZone(); + }); } - // -------------------------------------------- - Component.onCompleted: { - // Kickoff the cpu name detection for temperature - cpuTempNameReader.checkNext(); + onLoadFailed: function (error) { + currentZoneIndex++; + Qt.callLater(() => { + readNextCpuThermalZone(); + }); } + } - // -------------------------------------------- - // Timer for periodic updates - Timer { - id: updateTimer - - interval: root.sleepDuration - repeat: true - running: true - triggeredOnStart: true - onTriggered: { - // Trigger all direct system files reads - memInfoFile.reload(); - cpuStatFile.reload(); - // Run df (disk free) one time - dfProcess.running = true; - updateCpuTemperature(); - } + function readNextCpuThermalZone() { + if (cpuThermalZoneReader.currentZoneIndex >= root.cpuThermalZonePaths.length) { + if (cpuThermalZoneReader.collectedTemps.length > 0) { + root.cpuTemp = Math.round(Math.max(...cpuThermalZoneReader.collectedTemps)); + } else { + root.cpuTemp = 0; + } + root.pushCpuTempHistory(); + return; } + cpuThermalZoneReader.path = root.cpuThermalZonePaths[cpuThermalZoneReader.currentZoneIndex]; + cpuThermalZoneReader.reload(); + } - Timer { - id: fasterUpdateTimer + // -------------------------------------------- + // Parse ZFS ARC stats from /proc/spl/kstat/zfs/arcstats + function parseZfsArcStats(text) { + if (!text) + return; + const lines = text.split('\n'); - interval: root.fasterSleepDuration - repeat: true - running: true - triggeredOnStart: true - onTriggered: { - netDevFile.reload(); - } - } + // The file format is: name type data + // We need to find the lines with "size" and "c_min" and extract the values (third column) + let foundSize = false; + let foundCmin = false; - // -------------------------------------------- - // FileView components for reading system files - FileView { - id: memInfoFile - - path: "/proc/meminfo" - onLoaded: parseMemoryInfo(text()) - } - - FileView { - id: cpuStatFile - - path: "/proc/stat" - onLoaded: calculateCpuUsage(text()) - } - - FileView { - id: netDevFile - - path: "/proc/net/dev" - onLoaded: calculateNetworkSpeed(text()) - } - - // -------------------------------------------- - // Process to fetch disk usage in percent - // Uses 'df' aka 'disk free' - Process { - id: dfProcess - - command: ["df", "--output=pcent", "/"] - running: false - - stdout: StdioCollector { - onStreamFinished: { - const lines = text.trim().split('\n'); - if (lines.length >= 2) { - const percent = lines[1].replace(/[^0-9]/g, ''); - root.diskPercent = parseInt(percent) || 0; - } - } + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + if (parts[0] === 'size') { + // The value is in bytes, convert to KB + const arcSizeBytes = parseInt(parts[2]) || 0; + root.zfsArcSizeKb = Math.floor(arcSizeBytes / 1024); + foundSize = true; + } else if (parts[0] === 'c_min') { + // The value is in bytes, convert to KB + const arcCminBytes = parseInt(parts[2]) || 0; + root.zfsArcCminKb = Math.floor(arcCminBytes / 1024); + foundCmin = true; } + // If we found both, we can return early + if (foundSize && foundCmin) { + return; + } + } } - // -------------------------------------------- - // -------------------------------------------- - // CPU Temperature - // It's more complex. - // ---- - // #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower" - FileView { - id: cpuTempNameReader + // If fields not found, set to 0 + if (!foundSize) { + root.zfsArcSizeKb = 0; + } + if (!foundCmin) { + root.zfsArcCminKb = 0; + } + } - property int currentIndex: 0 + // -------------------------------------------- + // Parse load average from /proc/loadavg + function parseLoadAverage(text) { + if (!text) + return; + const parts = text.trim().split(/\s+/); + if (parts.length >= 3) { + root.loadAvg1 = parseFloat(parts[0]); + root.loadAvg5 = parseFloat(parts[1]); + root.loadAvg15 = parseFloat(parts[2]); + } + } - function checkNext() { - if (currentIndex >= 16) { - // Check up to hwmon10 - Logger.warn("SystemStatService", "No supported temperature sensor found"); - return ; - } - cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`; - cpuTempNameReader.reload(); - } + // -------------------------------------------- + // Parse memory info from /proc/meminfo + function parseMemoryInfo(text) { + if (!text) + return; + const lines = text.split('\n'); + let memTotal = 0; + let memAvailable = 0; - printErrors: false - onLoaded: { - const name = text().trim(); - if (root.supportedTempCpuSensorNames.includes(name)) { - root.cpuTempSensorName = name; - root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`; - Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`); - } else { - currentIndex++; - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNext(); - }); - } - } - onLoadFailed: function(error) { - currentIndex++; - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNext(); - }); - } + for (const line of lines) { + if (line.startsWith('MemTotal:')) { + memTotal = parseInt(line.split(/\s+/)[1]) || 0; + } else if (line.startsWith('MemAvailable:')) { + memAvailable = parseInt(line.split(/\s+/)[1]) || 0; + } } - // ---- - // #2 - Read sensor value - FileView { - id: cpuTempReader + if (memTotal > 0) { + // Calculate usage, adjusting for ZFS ARC cache if present + let usageKb = memTotal - memAvailable; + if (root.zfsArcSizeKb > 0) { + usageKb = Math.max(0, usageKb - root.zfsArcSizeKb + root.zfsArcCminKb); + } + root.memGb = (usageKb / 1048576).toFixed(1); // 1024*1024 = 1048576 + root.memPercent = Math.round((usageKb / memTotal) * 100); + root.memTotalGb = (memTotal / 1048576).toFixed(1); + root.pushMemHistory(); + } + } - printErrors: false - onLoaded: { - const data = text().trim(); - if (root.cpuTempSensorName === "coretemp") { - // For Intel, collect all temperature values - const temp = parseInt(data) / 1000; - root.intelTempValues.push(temp); - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNextIntelTemp(); - }); - } else { - // For AMD sensors (k10temp and zenpower), directly set the temperature - root.cpuTemp = Math.round(parseInt(data) / 1000); - } - } - onLoadFailed: function(error) { - Qt.callLater(() => { - // Qt.callLater is mandatory - checkNextIntelTemp(); - }); - } + // -------------------------------------------- + // Calculate CPU usage from /proc/stat + function calculateCpuUsage(text) { + if (!text) + return; + const lines = text.split('\n'); + const cpuLine = lines[0]; + + // First line is total CPU + if (!cpuLine.startsWith('cpu ')) + return; + const parts = cpuLine.split(/\s+/); + const stats = { + "user": parseInt(parts[1]) || 0, + "nice": parseInt(parts[2]) || 0, + "system": parseInt(parts[3]) || 0, + "idle": parseInt(parts[4]) || 0, + "iowait": parseInt(parts[5]) || 0, + "irq": parseInt(parts[6]) || 0, + "softirq": parseInt(parts[7]) || 0, + "steal": parseInt(parts[8]) || 0, + "guest": parseInt(parts[9]) || 0, + "guestNice": parseInt(parts[10]) || 0 + }; + const totalIdle = stats.idle + stats.iowait; + const total = Object.values(stats).reduce((sum, val) => sum + val, 0); + + if (root.prevCpuStats) { + const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait; + const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => sum + val, 0); + + const diffTotal = total - prevTotal; + const diffIdle = totalIdle - prevTotalIdle; + + if (diffTotal > 0) { + root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1); + } + root.pushCpuHistory(); } + root.prevCpuStats = stats; + } + + // -------------------------------------------- + // Check whether a network interface is virtual/tunnel/bridge. + // Only physical interfaces (eth*, en*, wl*, ww*) are kept so + // that traffic routed through VPNs, Docker bridges, etc. is + // not double-counted. + readonly property var _virtualPrefixes: ["lo", "docker", "veth", "br-", "virbr", "vnet", "tun", "tap", "wg", "tailscale", "nordlynx", "proton", "mullvad", "flannel", "cni", "cali", "vxlan", "genev", "gre", "sit", "ip6tnl", "dummy", "ifb", "nlmon", "bond"] + + function isVirtualInterface(name) { + for (let i = 0; i < _virtualPrefixes.length; ++i) { + if (name.startsWith(_virtualPrefixes[i])) + return true; + } + return false; + } + + // -------------------------------------------- + // Calculate RX and TX speed from /proc/net/dev + // Sums speeds of all physical interfaces + function calculateNetworkSpeed(text) { + if (!text) { + return; + } + + const currentTime = Date.now() / 1000; + const lines = text.split('\n'); + + let totalRx = 0; + let totalTx = 0; + + for (var i = 2; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) { + continue; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex === -1) { + continue; + } + + const iface = line.substring(0, colonIndex).trim(); + if (isVirtualInterface(iface)) { + continue; + } + + const statsLine = line.substring(colonIndex + 1).trim(); + const stats = statsLine.split(/\s+/); + + const rxBytes = parseInt(stats[0], 10) || 0; + const txBytes = parseInt(stats[8], 10) || 0; + + totalRx += rxBytes; + totalTx += txBytes; + } + + // Compute only if we have a previous run to compare to. + if (root.prevTime > 0) { + const timeDiff = currentTime - root.prevTime; + + // Avoid division by zero if time hasn't passed. + if (timeDiff > 0) { + let rxDiff = totalRx - root.prevRxBytes; + let txDiff = totalTx - root.prevTxBytes; + + // Handle counter resets (e.g., WiFi reconnect), which would cause a negative value. + if (rxDiff < 0) { + rxDiff = 0; + } + if (txDiff < 0) { + txDiff = 0; + } + + root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s + root.txSpeed = Math.round(txDiff / timeDiff); + } + } + + root.prevRxBytes = totalRx; + root.prevTxBytes = totalTx; + root.prevTime = currentTime; + + // Update network history after speeds are computed + root.pushNetworkHistory(); + } + + // -------------------------------------------- + // Helper function to format network speeds + function formatSpeed(bytesPerSecond) { + const units = ["KB", "MB", "GB"]; + let value = bytesPerSecond / 1000; + let unitIndex = 0; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex++; + } + + const unit = units[unitIndex]; + const shortUnit = unit[0]; + const numStr = value < 10 ? value.toFixed(1) : Math.round(value).toString(); + + return (numStr + unit).length > 5 ? numStr + shortUnit : numStr + unit; + } + + // Compact speed formatter for vertical bar display + function formatCompactSpeed(bytesPerSecond) { + if (!bytesPerSecond || bytesPerSecond <= 0) + return "0"; + const units = ["", "K", "M", "G"]; + let value = bytesPerSecond; + let unitIndex = 0; + while (value >= 1000 && unitIndex < units.length - 1) { + value = value / 1000.0; + unitIndex++; + } + // Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded) + if (unitIndex < units.length - 1 && value >= 100) { + value = value / 1000.0; + unitIndex++; + } + const display = Math.round(value).toString(); + return display + units[unitIndex]; + } + + // Smart formatter for memory values (GB) - max 4 chars + // Uses decimal for < 10GB, integer otherwise + function formatGigabytes(memGb) { + const value = parseFloat(memGb); + if (isNaN(value)) + return "0G"; + + if (value < 10) + return value.toFixed(1) + "G"; // "0.0G" to "9.9G" + return Math.round(value) + "G"; // "10G" to "999G" + } + + // Formatting gigabytes with optional padding + function formatGigabytesDisplay(memGb, maxGb = null) { + const value = formatGigabytes(memGb === null ? 0 : memGb); + if (maxGb !== null) { + const padding = Math.max(4, formatGigabytes(maxGb).length); + return value.padStart(padding, " "); + } + return value; + } + + // Formatting percentage with optional padding + function formatPercentageDisplay(value, padding = false) { + return `${Math.round(value === null ? 0 : value)}%`.padStart(padding ? 4 : 0, " "); + } + + // Formatting ram usage + function formatRamDisplay({ + percent = false, + padding = false +} = {}) { + if (percent) { + return formatPercentageDisplay(memPercent, padding); + } else { + const maxGb = padding ? memTotalGb : null; + return formatGigabytesDisplay(memGb, maxGb); + } + } + + // -------------------------------------------- + // Function to start fetching and computing the cpu temperature + function updateCpuTemperature() { + // For AMD sensors (k10temp and zenpower), only use Tctl sensor + // temp1_input corresponds to Tctl (Temperature Control) on these sensors + if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") { + cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`; + cpuTempReader.reload(); + } // For Intel coretemp, start averaging all available sensors/cores + else if (root.cpuTempSensorName === "coretemp") { + root.intelTempValues = []; + root.intelTempFilesChecked = 0; + checkNextIntelTemp(); + } // For thermal_zone fallback (ARM SoCs, SCMI, etc.), read all CPU zones and take max + else if (root.cpuTempSensorName === "thermal_zone") { + cpuThermalZoneReader.currentZoneIndex = 0; + cpuThermalZoneReader.collectedTemps = []; + readNextCpuThermalZone(); + } + } + + // -------------------------------------------- + // Function to check next Intel temperature sensor + function checkNextIntelTemp() { + if (root.intelTempFilesChecked >= root.intelTempMaxFiles) { + // Calculate average of all found temperatures + if (root.intelTempValues.length > 0) { + let sum = 0; + for (var i = 0; i < root.intelTempValues.length; i++) { + sum += root.intelTempValues[i]; + } + root.cpuTemp = Math.round(sum / root.intelTempValues.length); + root.pushCpuTempHistory(); + } else { + Logger.w("SystemStat", "No temperature sensors found for coretemp"); + root.cpuTemp = 0; + root.pushCpuTempHistory(); + } + return; + } + + // Check next temperature file + root.intelTempFilesChecked++; + cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`; + cpuTempReader.reload(); + } } diff --git a/config/quickshell/.config/quickshell/Services/ThemeIcons.qml b/config/quickshell/.config/quickshell/Services/ThemeIcons.qml index ba3203c..c4e861b 100644 --- a/config/quickshell/.config/quickshell/Services/ThemeIcons.qml +++ b/config/quickshell/.config/quickshell/Services/ThemeIcons.qml @@ -1,46 +1,289 @@ -import QtQuick -import Quickshell pragma Singleton +import QtQuick +import Quickshell +import qs.Utils + Singleton { - // ignore and fall back + id: root - id: root + property real scoreThreshold: 0.2 - function iconFromName(iconName, fallbackName) { - const fallback = fallbackName || "application-x-executable"; - try { - if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) { - const p = Quickshell.iconPath(iconName, fallback); - if (p && p !== "") - return p; + // Manual overrides for tricky apps + property var substitutions: ({ + "code-url-handler": "visual-studio-code", + "Code": "visual-studio-code", + "gnome-tweaks": "org.gnome.tweaks", + "pavucontrol-qt": "pavucontrol", + "wps": "wps-office2019-kprometheus", + "wpsoffice": "wps-office2019-kprometheus", + "footclient": "foot" + }) - } - } catch (e) { - } - try { - return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : ""; - } catch (e2) { - return ""; - } + // Dynamic fixups + property var regexSubstitutions: [ + { + "regex": /^steam_app_(\d+)$/, + "replace": "steam_icon_$1" + }, + { + "regex": /Minecraft.*/, + "replace": "minecraft-launcher" + }, + { + "regex": /.*polkit.*/, + "replace": "system-lock-screen" + }, + { + "regex": /gcr.prompter/, + "replace": "system-lock-screen" + } + ] + + property list entryList: [] + property var preppedNames: [] + property var preppedIcons: [] + property var preppedIds: [] + + Component.onCompleted: refreshEntries() + + Connections { + target: DesktopEntries.applications + function onValuesChanged() { + refreshEntries(); + } + } + + function refreshEntries() { + if (typeof DesktopEntries === 'undefined') + return; + + const values = Array.from(DesktopEntries.applications.values); + if (values) { + entryList = values.sort((a, b) => a.name.localeCompare(b.name)); + updatePreppedData(); + } + } + + function updatePreppedData() { + if (typeof FuzzySort === 'undefined') + return; + + const list = Array.from(entryList); + preppedNames = list.map(a => ({ + name: FuzzySort.prepare(`${a.name} `), + entry: a + })); + preppedIcons = list.map(a => ({ + name: FuzzySort.prepare(`${a.icon} `), + entry: a + })); + preppedIds = list.map(a => ({ + name: FuzzySort.prepare(`${a.id} `), + entry: a + })); + } + + function iconForAppId(appId, fallbackName) { + const fallback = fallbackName || "application-x-executable"; + if (!appId) + return iconFromName(fallback, fallback); + + const entry = findAppEntry(appId); + if (entry) { + return iconFromName(entry.icon, fallback); } - // Resolve icon path for a DesktopEntries appId - safe on missing entries - function iconForAppId(appId, fallbackName) { - const fallback = fallbackName || "application-x-executable"; - if (!appId) - return iconFromName(fallback, fallback); + return iconFromName(appId, fallback); + } - try { - if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) - return iconFromName(fallback, fallback); + // Robust lookup strategy + function findAppEntry(str) { + if (!str || str.length === 0) + return null; - const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId); - const name = entry && entry.icon ? entry.icon : ""; - return iconFromName(name || fallback, fallback); - } catch (e) { - return iconFromName(fallback, fallback); - } + let result = null; + + if (result = checkHeuristic(str)) + return result; + if (result = checkSubstitutions(str)) + return result; + if (result = checkRegex(str)) + return result; + if (result = checkSimpleTransforms(str)) + return result; + if (result = checkFuzzySearch(str)) + return result; + if (result = checkCleanMatch(str)) + return result; + + return null; + } + + function iconFromName(iconName, fallbackName) { + const fallback = fallbackName || "application-x-executable"; + try { + if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) { + const p = Quickshell.iconPath(iconName, fallback); + if (p && p !== "") + return p; + } + } catch (e) {} + + try { + return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : ""; + } catch (e2) { + return ""; + } + } + + function distroLogoPath() { + try { + return (typeof OSInfo !== 'undefined' && OSInfo.distroIconPath) ? OSInfo.distroIconPath : ""; + } catch (e) { + return ""; + } + } + + // --- Lookup Helpers --- + + function checkHeuristic(str) { + if (typeof DesktopEntries !== 'undefined' && DesktopEntries.heuristicLookup) { + const entry = DesktopEntries.heuristicLookup(str); + if (entry) + return entry; + } + return null; + } + + function checkSubstitutions(str) { + let effectiveStr = substitutions[str]; + if (!effectiveStr) + effectiveStr = substitutions[str.toLowerCase()]; + + if (effectiveStr && effectiveStr !== str) { + return findAppEntry(effectiveStr); + } + return null; + } + + function checkRegex(str) { + for (let i = 0; i < regexSubstitutions.length; i++) { + const sub = regexSubstitutions[i]; + const replaced = str.replace(sub.regex, sub.replace); + if (replaced !== str) { + return findAppEntry(replaced); + } + } + return null; + } + + function checkSimpleTransforms(str) { + if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) + return null; + + const lower = str.toLowerCase(); + + const variants = [str, lower, getFromReverseDomain(str), getFromReverseDomain(str)?.toLowerCase(), normalizeWithHyphens(str), str.replace(/_/g, '-').toLowerCase(), str.replace(/-/g, '_').toLowerCase()]; + + for (let i = 0; i < variants.length; i++) { + const variant = variants[i]; + if (variant) { + const entry = DesktopEntries.byId(variant); + if (entry) + return entry; + } + } + return null; + } + + function checkFuzzySearch(str) { + if (typeof FuzzySort === 'undefined') + return null; + + // Check filenames (IDs) first + if (preppedIds.length > 0) { + let results = fuzzyQuery(str, preppedIds); + if (results.length === 0) { + const underscored = str.replace(/-/g, '_').toLowerCase(); + if (underscored !== str) + results = fuzzyQuery(underscored, preppedIds); + } + if (results.length > 0) + return results[0]; } + // Then icons + if (preppedIcons.length > 0) { + const results = fuzzyQuery(str, preppedIcons); + if (results.length > 0) + return results[0]; + } + + // Then names + if (preppedNames.length > 0) { + const results = fuzzyQuery(str, preppedNames); + if (results.length > 0) + return results[0]; + } + + return null; + } + + function checkCleanMatch(str) { + if (!str || str.length <= 3) + return null; + if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId) + return null; + + // Aggressive fallback: strip all separators + const cleanStr = str.toLowerCase().replace(/[\.\-_]/g, ''); + const list = Array.from(entryList); + + for (let i = 0; i < list.length; i++) { + const entry = list[i]; + const cleanId = (entry.id || "").toLowerCase().replace(/[\.\-_]/g, ''); + if (cleanId.includes(cleanStr) || cleanStr.includes(cleanId)) { + return entry; + } + } + return null; + } + + function fuzzyQuery(search, preppedData) { + if (!search || !preppedData || preppedData.length === 0) + return []; + return FuzzySort.go(search, preppedData, { + all: true, + key: "name" + }).map(r => r.obj.entry); + } + + function iconExists(iconName) { + if (!iconName || iconName.length === 0) + return false; + if (iconName.startsWith("/")) + return true; + + const path = Quickshell.iconPath(iconName, true); + return path && path.length > 0 && !path.includes("image-missing"); + } + + function getFromReverseDomain(str) { + if (!str) + return ""; + return str.split('.').slice(-1)[0]; + } + + function normalizeWithHyphens(str) { + if (!str) + return ""; + return str.toLowerCase().replace(/\s+/g, "-"); + } + + // Deprecated shim + function guessIcon(str) { + const entry = findAppEntry(str); + return entry ? entry.icon : "image-missing"; + } } diff --git a/config/quickshell/.config/quickshell/Services/WorkspaceManager.qml b/config/quickshell/.config/quickshell/Services/WorkspaceManager.qml deleted file mode 100644 index 7d34859..0000000 --- a/config/quickshell/.config/quickshell/Services/WorkspaceManager.qml +++ /dev/null @@ -1,56 +0,0 @@ -pragma Singleton -pragma ComponentBehavior: Bound - -import QtQuick -import Quickshell -import Quickshell.Io -import qs.Services -import qs.Utils - -Singleton { - id: root - - property ListModel workspaces - - workspaces: ListModel { - } - - function initNiri() { - updateNiriWorkspaces(); - } - - function updateNiriWorkspaces() { - const niriWorkspaces = Niri.workspaces || []; - workspaces.clear(); - for (let i = 0; i < niriWorkspaces.length; i++) { - const ws = niriWorkspaces[i]; - workspaces.append({ - "id": ws.id, - "idx": ws.idx || 1, - "name": ws.name || "", - "output": ws.output || "", - "isFocused": ws.isFocused === true, - "isActive": ws.isActive === true, - "isUrgent": ws.isUrgent === true - }); - } - workspacesChanged(); - } - - function switchToWorkspace(workspaceId) { - try { - Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]); - } catch (e) { - Logger.error("WorkspaceManager", "Error switching Niri workspace:", e); - } - } - - Connections { - function onWorkspacesChanged() { - updateNiriWorkspaces(); - } - - target: Niri - } - -} diff --git a/config/quickshell/.config/quickshell/Utils/sha256.js b/config/quickshell/.config/quickshell/Services/sha256.js similarity index 100% rename from config/quickshell/.config/quickshell/Utils/sha256.js rename to config/quickshell/.config/quickshell/Services/sha256.js diff --git a/config/quickshell/.config/quickshell/Shaders/frag/rounded_image.frag b/config/quickshell/.config/quickshell/Shaders/frag/rounded_image.frag new file mode 100644 index 0000000..164906d --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/rounded_image.frag @@ -0,0 +1,98 @@ +#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 sourceWidth; + float sourceHeight; + float cornerRadius; + float imageOpacity; + int fillMode; +} 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); + vec2 sourceSize = vec2(ubuf.sourceWidth, ubuf.sourceHeight); + float cornerRadius = ubuf.cornerRadius; + float itemOpacity = ubuf.imageOpacity; + int fillMode = ubuf.fillMode; + + // Work in pixel space for accurate rounded rectangle calculation + vec2 pixelPos = qt_TexCoord0 * itemSize; + + // Calculate UV coordinates based on fill mode + vec2 imageUV = qt_TexCoord0; + + // fillMode constants from Qt: + // Image.Stretch = 0 + // Image.PreserveAspectFit = 1 + // Image.PreserveAspectCrop = 2 + // Image.Tile = 3 + // Image.TileVertically = 4 + // Image.TileHorizontally = 5 + // Image.Pad = 6 + + // Rounded corners always apply to full item bounds + vec2 roundedSize = itemSize; + vec2 roundedCenter = itemSize * 0.5; + + // Track if pixel is in letterbox area (for PreserveAspectFit) + bool inLetterbox = false; + + if (fillMode == 1) { // PreserveAspectFit + float itemAspect = itemSize.x / itemSize.y; + float sourceAspect = sourceSize.x / sourceSize.y; + + if (sourceAspect > itemAspect) { + // Image is wider than item, letterbox top/bottom + imageUV.y = (qt_TexCoord0.y - 0.5) * (sourceAspect / itemAspect) + 0.5; + } else { + // Image is taller than item, letterbox left/right + imageUV.x = (qt_TexCoord0.x - 0.5) * (itemAspect / sourceAspect) + 0.5; + } + + // Check if in letterbox area + inLetterbox = (imageUV.x < 0.0 || imageUV.x > 1.0 || imageUV.y < 0.0 || imageUV.y > 1.0); + } else if (fillMode == 2) { // PreserveAspectCrop + float itemAspect = itemSize.x / itemSize.y; + float sourceAspect = sourceSize.x / sourceSize.y; + + if (sourceAspect > itemAspect) { + // Image is wider than item, crop left/right. + imageUV.x = (qt_TexCoord0.x - 0.5) * (itemAspect / sourceAspect) + 0.5; + } else { + // Image is taller than item, crop top/bottom. + imageUV.y = (qt_TexCoord0.y - 0.5) * (sourceAspect / itemAspect) + 0.5; + } + } + // For Stretch (0) or other modes, use qt_TexCoord0 as-is + + // Calculate distance to rounded rectangle edge using the correct bounds + vec2 centerOffset = pixelPos - roundedCenter; + float distance = roundedBoxSDF(centerOffset, roundedSize * 0.5, cornerRadius); + + // Create smooth alpha mask for edge with anti-aliasing + float alpha = 1.0 - smoothstep(-0.5, 0.5, distance); + + // Sample the texture (or use transparent for letterbox) + vec4 color = inLetterbox ? vec4(0.0) : texture(source, imageUV); + + // Apply the rounded mask and opacity + float finalAlpha = color.a * alpha * itemOpacity * ubuf.qt_Opacity; + fragColor = vec4(color.rgb * finalAlpha, finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_cloud.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_cloud.frag new file mode 100644 index 0000000..1991ce3 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_cloud.frag @@ -0,0 +1,123 @@ +#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 time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; + float alternative; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +float hash(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(p.x * p.y); +} + +// Perlin-like noise +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); // Smooth interpolation + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +// Turbulent noise for natural fog +float turbulence(vec2 p, float iTime) { + float t = 0.0; + float scale = 1.0; + for(int i = 0; i < 5; i++) { + t += abs(noise(p * scale + iTime * 0.1 * scale)) / scale; + scale *= 2.0; + } + return t; +} + +void main() { + vec2 uv = qt_TexCoord0; + + vec4 col = vec4(ubuf.bgColor.rgb, 1.0); + + // Different parameters for fog vs clouds + float timeSpeed, layerScale1, layerScale2, layerScale3; + float flowSpeed1, flowSpeed2; + float densityMin, densityMax; + float baseOpacity; + float pulseAmount; + + if (ubuf.alternative > 0.5) { + // Fog: slower, larger scale, more uniform + timeSpeed = 0.03; + layerScale1 = 1.0; + layerScale2 = 2.5; + layerScale3 = 2.0; + flowSpeed1 = 0.00; + flowSpeed2 = 0.02; + densityMin = 0.1; + densityMax = 0.9; + baseOpacity = 0.75; + pulseAmount = 0.05; + } else { + // Clouds: faster, smaller scale, puffier + timeSpeed = 0.08; + layerScale1 = 2.0; + layerScale2 = 4.0; + layerScale3 = 6.0; + flowSpeed1 = 0.03; + flowSpeed2 = 0.04; + densityMin = 0.35; + densityMax = 0.75; + baseOpacity = 0.4; + pulseAmount = 0.15; + } + + float iTime = ubuf.time * timeSpeed; + + // Create flowing patterns with multiple layers + vec2 flow1 = vec2(iTime * flowSpeed1, iTime * flowSpeed1 * 0.7); + vec2 flow2 = vec2(-iTime * flowSpeed2, iTime * flowSpeed2 * 0.8); + + float fog1 = noise(uv * layerScale1 + flow1); + float fog2 = noise(uv * layerScale2 + flow2); + float fog3 = turbulence(uv * layerScale3, iTime); + + float fogPattern = fog1 * 0.5 + fog2 * 0.3 + fog3 * 0.2; + float fogDensity = smoothstep(densityMin, densityMax, fogPattern); + + // Gentle pulsing + float pulse = sin(iTime * 0.4) * pulseAmount + (1.0 - pulseAmount); + fogDensity *= pulse; + + vec3 hazeColor = vec3(0.88, 0.90, 0.93); + float hazeOpacity = fogDensity * baseOpacity; + vec3 fogContribution = hazeColor * hazeOpacity; + float fogAlpha = hazeOpacity; + + vec3 resultRGB = fogContribution + col.rgb * (1.0 - fogAlpha); + float resultAlpha = fogAlpha + col.a * (1.0 - fogAlpha); + + // Calculate corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Apply global opacity and corner mask + float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_rain.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_rain.frag new file mode 100644 index 0000000..bd3bc39 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_rain.frag @@ -0,0 +1,84 @@ +#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 time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +vec3 hash3(vec2 p) { + vec3 q = vec3(dot(p, vec2(127.1, 311.7)), + dot(p, vec2(269.5, 183.3)), + dot(p, vec2(419.2, 371.9))); + return fract(sin(q) * 43758.5453); +} + +float noise(vec2 x, float iTime) { + vec2 p = floor(x); + vec2 f = fract(x); + + float va = 0.0; + for (int j = -2; j <= 2; j++) { + for (int i = -2; i <= 2; i++) { + vec2 g = vec2(float(i), float(j)); + vec3 o = hash3(p + g); + vec2 r = g - f + o.xy; + float d = sqrt(dot(r, r)); + float ripple = max(mix(smoothstep(0.99, 0.999, max(cos(d - iTime * 2.0 + (o.x + o.y) * 5.0), 0.0)), 0.0, d), 0.0); + va += ripple; + } + } + + return va; +} + +void main() { + vec2 uv = qt_TexCoord0; + float iTime = ubuf.time * 0.07; + + // Aspect ratio correction for circular ripples + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uvAspect = vec2(uv.x * aspect, uv.y); + + float f = noise(6.0 * uvAspect, iTime) * smoothstep(0.0, 0.2, sin(uv.x * 3.141592) * sin(uv.y * 3.141592)); + + // Calculate normal from noise for distortion + float normalScale = 0.5; + vec2 e = normalScale / vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 eAspect = vec2(e.x * aspect, e.y); + float cx = noise(6.0 * (uvAspect + eAspect), iTime) * smoothstep(0.0, 0.2, sin((uv.x + e.x) * 3.141592) * sin(uv.y * 3.141592)); + float cy = noise(6.0 * (uvAspect + eAspect.yx), iTime) * smoothstep(0.0, 0.2, sin(uv.x * 3.141592) * sin((uv.y + e.y) * 3.141592)); + vec2 n = vec2(cx - f, cy - f); + + // Scale distortion back to texture space (undo aspect correction for X) + vec2 distortion = vec2(n.x / aspect, n.y); + + // Sample source with distortion + vec4 col = texture(source, uv + distortion); + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Output with premultiplied alpha + float finalAlpha = col.a * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(col.rgb * finalAlpha, finalAlpha); +} diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_snow.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_snow.frag new file mode 100644 index 0000000..e00a952 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_snow.frag @@ -0,0 +1,75 @@ +#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 time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +void main() { + // Aspect ratio correction + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uv = qt_TexCoord0; + uv.x *= aspect; + uv.y = 1.0 - uv.y; + + float iTime = ubuf.time * 0.15; + + float snow = 0.0; + + for (int k = 0; k < 6; k++) { + for (int i = 0; i < 12; i++) { + float cellSize = 2.0 + (float(i) * 3.0); + float downSpeed = 0.3 + (sin(iTime * 0.4 + float(k + i * 20)) + 1.0) * 0.00008; + + vec2 uvAnim = uv + vec2( + 0.01 * sin((iTime + float(k * 6185)) * 0.6 + float(i)) * (5.0 / float(i + 1)), + downSpeed * (iTime + float(k * 1352)) * (1.0 / float(i + 1)) + ); + + vec2 uvStep = (ceil((uvAnim) * cellSize - vec2(0.5, 0.5)) / cellSize); + float x = fract(sin(dot(uvStep.xy, vec2(12.9898 + float(k) * 12.0, 78.233 + float(k) * 315.156))) * 43758.5453 + float(k) * 12.0) - 0.5; + float y = fract(sin(dot(uvStep.xy, vec2(62.2364 + float(k) * 23.0, 94.674 + float(k) * 95.0))) * 62159.8432 + float(k) * 12.0) - 0.5; + + float randomMagnitude1 = sin(iTime * 2.5) * 0.7 / cellSize; + float randomMagnitude2 = cos(iTime * 1.65) * 0.7 / cellSize; + + float d = 5.0 * distance((uvStep.xy + vec2(x * sin(y), y) * randomMagnitude1 + vec2(y, x) * randomMagnitude2), uvAnim.xy); + + float omiVal = fract(sin(dot(uvStep.xy, vec2(32.4691, 94.615))) * 31572.1684); + if (omiVal < 0.03) { + float newd = (x + 1.0) * 0.4 * clamp(1.9 - d * (15.0 + (x * 6.3)) * (cellSize / 1.4), 0.0, 1.0); + snow += newd; + } + } + } + + // Blend white snow over background color + float snowAlpha = clamp(snow * 2.0, 0.0, 1.0); + vec3 snowColor = vec3(1.0); + vec3 blended = mix(ubuf.bgColor.rgb, snowColor, snowAlpha); + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Output with premultiplied alpha + float finalAlpha = ubuf.qt_Opacity * cornerMask; + fragColor = vec4(blended * finalAlpha, finalAlpha); +} diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_stars.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_stars.frag new file mode 100644 index 0000000..1048cae --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_stars.frag @@ -0,0 +1,130 @@ +#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 time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +float hash(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(p.x * p.y); +} + +vec2 hash2(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(vec2(p.x * p.y, p.y * p.x)); +} + +float stars(vec2 uv, float density, float iTime) { + vec2 gridUV = uv * density; + vec2 gridID = floor(gridUV); + vec2 gridPos = fract(gridUV); + + float starField = 0.0; + + // Check neighboring cells for stars + for (int y = -1; y <= 1; y++) { + for (int x = -1; x <= 1; x++) { + vec2 offset = vec2(float(x), float(y)); + vec2 cellID = gridID + offset; + + // Random position within cell + vec2 starPos = hash2(cellID); + + // Only create a star for some cells (sparse distribution) + float starChance = hash(cellID + vec2(12.345, 67.890)); + if (starChance > 0.85) { + // Star position in grid space + vec2 toStar = (offset + starPos - gridPos); + float dist = length(toStar) * density; // Scale distance to pixel space + + float starSize = 1.5; + + // Star brightness variation + float brightness = hash(cellID + vec2(23.456, 78.901)) * 0.6 + 0.4; + + // Twinkling effect + float twinkleSpeed = hash(cellID + vec2(34.567, 89.012)) * 3.0 + 2.0; + float twinklePhase = iTime * twinkleSpeed + hash(cellID) * 6.28; + float twinkle = pow(sin(twinklePhase) * 0.5 + 0.5, 3.0); // Sharp on/off + + // Sharp star core + float star = 0.0; + if (dist < starSize) { + star = 1.0 * brightness * (0.3 + twinkle * 0.7); + + // Add tiny cross-shaped glow for brighter stars + if (brightness > 0.7) { + float crossGlow = max( + exp(-abs(toStar.x) * density * 5.0), + exp(-abs(toStar.y) * density * 5.0) + ) * 0.3 * twinkle; + star += crossGlow; + } + } + + starField += star; + } + } + } + + return starField; +} + +void main() { + vec2 uv = qt_TexCoord0; + float iTime = ubuf.time * 0.01; + + // Base background color + vec4 col = vec4(ubuf.bgColor.rgb, 1.0); + + // Aspect ratio for consistent stars + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uvAspect = vec2(uv.x * aspect, uv.y); + + // Generate multiple layers of stars at different densities + float stars1 = stars(uvAspect, 40.0, iTime); // Tiny distant stars + float stars2 = stars(uvAspect + vec2(0.5, 0.3), 25.0, iTime * 1.3); // Small stars + float stars3 = stars(uvAspect + vec2(0.25, 0.7), 15.0, iTime * 0.9); // Bigger stars + + // Star colors with slight variation + vec3 starColor1 = vec3(0.85, 0.9, 1.0); // Faint blue-white + vec3 starColor2 = vec3(0.95, 0.97, 1.0); // White + vec3 starColor3 = vec3(1.0, 0.98, 0.95); // Warm white + + // Combine star layers + vec3 starsRGB = starColor1 * stars1 * 0.6 + + starColor2 * stars2 * 0.8 + + starColor3 * stars3 * 1.0; + + float starsAlpha = clamp(stars1 * 0.6 + stars2 * 0.8 + stars3, 0.0, 1.0); + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + // Add stars on top + vec3 resultRGB = starsRGB * starsAlpha + col.rgb * (1.0 - starsAlpha); + float resultAlpha = starsAlpha + col.a * (1.0 - starsAlpha); + + // Apply global opacity and corner mask + float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/frag/weather_sun.frag b/config/quickshell/.config/quickshell/Shaders/frag/weather_sun.frag new file mode 100644 index 0000000..bfc0cd9 --- /dev/null +++ b/config/quickshell/.config/quickshell/Shaders/frag/weather_sun.frag @@ -0,0 +1,148 @@ +#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 time; + float itemWidth; + float itemHeight; + vec4 bgColor; + float cornerRadius; +} ubuf; + +// Signed distance function for rounded rectangle +float roundedBoxSDF(vec2 center, vec2 size, float radius) { + vec2 q = abs(center) - size + radius; + return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius; +} + +float hash(vec2 p) { + p = fract(p * vec2(234.34, 435.345)); + p += dot(p, p + 34.23); + return fract(p.x * p.y); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +// God rays originating from sun position +float sunRays(vec2 uv, vec2 sunPos, float iTime) { + vec2 toSun = uv - sunPos; + float angle = atan(toSun.y, toSun.x); + float dist = length(toSun); + + float rayCount = 7; + + // Radial pattern + float rays = sin(angle * rayCount + sin(iTime * 0.25)) * 0.5 + 0.5; + rays = pow(rays, 3.0); + + // Fade with distance + float falloff = 1.0 - smoothstep(0.0, 1.2, dist); + + return rays * falloff * 0.15; +} + +// Atmospheric shimmer / heat haze +float atmosphericShimmer(vec2 uv, float iTime) { + // Multiple layers of noise for complexity + float n1 = noise(uv * 5.0 + vec2(iTime * 0.1, iTime * 0.05)); + float n2 = noise(uv * 8.0 - vec2(iTime * 0.08, iTime * 0.12)); + float n3 = noise(uv * 12.0 + vec2(iTime * 0.15, -iTime * 0.1)); + + return (n1 * 0.5 + n2 * 0.3 + n3 * 0.2) * 0.15; +} + +float sunCore(vec2 uv, vec2 sunPos, float iTime) { + vec2 toSun = uv - sunPos; + float dist = length(toSun); + + // Main bright spot + float mainFlare = exp(-dist * 15.0) * 2.0; + + // Secondary reflection spots along the line + float flares = 0.0; + for (int i = 1; i <= 3; i++) { + vec2 flarePos = sunPos + toSun * float(i) * 0.3; + float flareDist = length(uv - flarePos); + float flareSize = 0.02 + float(i) * 0.01; + flares += smoothstep(flareSize * 2.0, flareSize * 0.5, flareDist) * (0.3 / float(i)); + } + + // Pulsing effect + float pulse = sin(iTime) * 0.1 + 0.9; + + return (mainFlare + flares) * pulse; +} + +void main() { + vec2 uv = qt_TexCoord0; + float iTime = ubuf.time * 0.08; + + // Sample the source + vec4 col = vec4(ubuf.bgColor.rgb, 1.0); + + vec2 sunPos = vec2(0.85, 0.2); + + // Aspect ratio correction + float aspect = ubuf.itemWidth / ubuf.itemHeight; + vec2 uvAspect = vec2(uv.x * aspect, uv.y); + vec2 sunPosAspect = vec2(sunPos.x * aspect, sunPos.y); + + // Generate sunny effects + float rays = sunRays(uvAspect, sunPosAspect, iTime); + float shimmerEffect = atmosphericShimmer(uv, iTime); + float flare = sunCore(uvAspect, sunPosAspect, iTime); + + // Warm sunny colors + vec3 sunColor = vec3(1.0, 0.95, 0.7); // Warm golden yellow + vec3 skyColor = vec3(0.9, 0.95, 1.0); // Light blue tint + vec3 shimmerColor = vec3(1.0, 0.98, 0.85); // Subtle warm shimmer + + // Apply rounded corner mask + vec2 pixelPos = qt_TexCoord0 * vec2(ubuf.itemWidth, ubuf.itemHeight); + vec2 center = pixelPos - vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + vec2 halfSize = vec2(ubuf.itemWidth, ubuf.itemHeight) * 0.5; + float dist = roundedBoxSDF(center, halfSize, ubuf.cornerRadius); + float cornerMask = 1.0 - smoothstep(-1.0, 0.0, dist); + + vec3 resultRGB = col.rgb; + float resultAlpha = col.a; + + // Add sun rays + vec3 raysContribution = sunColor * rays; + float raysAlpha = rays * 0.4; + resultRGB = raysContribution + resultRGB * (1.0 - raysAlpha); + resultAlpha = raysAlpha + resultAlpha * (1.0 - raysAlpha); + + // Add atmospheric shimmer + vec3 shimmerContribution = shimmerColor * shimmerEffect; + float shimmerAlpha = shimmerEffect * 0.1; + resultRGB = shimmerContribution + resultRGB * (1.0 - shimmerAlpha); + resultAlpha = shimmerAlpha + resultAlpha * (1.0 - shimmerAlpha); + + // Add bright sun core + vec3 flareContribution = sunColor * flare; + float flareAlpha = clamp(flare, 0.0, 1.0) * 0.6; + resultRGB = flareContribution + resultRGB * (1.0 - flareAlpha); + resultAlpha = flareAlpha + resultAlpha * (1.0 - flareAlpha); + + // Overall warm sunny tint + resultRGB = mix(resultRGB, resultRGB * vec3(1.08, 1.04, 0.98), 0.15); + + // Apply global opacity and corner mask + float finalAlpha = resultAlpha * ubuf.qt_Opacity * cornerMask; + fragColor = vec4(resultRGB * (finalAlpha / max(resultAlpha, 0.001)), finalAlpha); +} \ No newline at end of file diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/circled_image.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/circled_image.frag.qsb deleted file mode 100644 index 37a99ef..0000000 Binary files a/config/quickshell/.config/quickshell/Shaders/qsb/circled_image.frag.qsb and /dev/null differ diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/rounded_image.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/rounded_image.frag.qsb index c404fc7..b0efbb8 100644 Binary files a/config/quickshell/.config/quickshell/Shaders/qsb/rounded_image.frag.qsb and b/config/quickshell/.config/quickshell/Shaders/qsb/rounded_image.frag.qsb differ diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_cloud.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_cloud.frag.qsb new file mode 100644 index 0000000..2fe9734 Binary files /dev/null and b/config/quickshell/.config/quickshell/Shaders/qsb/weather_cloud.frag.qsb differ diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_rain.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_rain.frag.qsb new file mode 100644 index 0000000..5c06dd6 Binary files /dev/null and b/config/quickshell/.config/quickshell/Shaders/qsb/weather_rain.frag.qsb differ diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_snow.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_snow.frag.qsb new file mode 100644 index 0000000..e466857 Binary files /dev/null and b/config/quickshell/.config/quickshell/Shaders/qsb/weather_snow.frag.qsb differ diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_stars.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_stars.frag.qsb new file mode 100644 index 0000000..544c2b3 Binary files /dev/null and b/config/quickshell/.config/quickshell/Shaders/qsb/weather_stars.frag.qsb differ diff --git a/config/quickshell/.config/quickshell/Shaders/qsb/weather_sun.frag.qsb b/config/quickshell/.config/quickshell/Shaders/qsb/weather_sun.frag.qsb new file mode 100644 index 0000000..2b231fa Binary files /dev/null and b/config/quickshell/.config/quickshell/Shaders/qsb/weather_sun.frag.qsb differ diff --git a/config/quickshell/.config/quickshell/Utils/Cava.qml b/config/quickshell/.config/quickshell/Utils/Cava.qml index b60398e..e8c198b 100644 --- a/config/quickshell/.config/quickshell/Utils/Cava.qml +++ b/config/quickshell/.config/quickshell/Utils/Cava.qml @@ -38,7 +38,7 @@ Scope { id: process stdinEnabled: true - running: !root.forceDisable && (MusicManager.isPlaying || root.forceEnable) + running: !root.forceDisable && (MediaService.isPlaying || root.forceEnable) command: ["cava", "-p", "/dev/stdin"] onExited: { stdinEnabled = true; diff --git a/config/quickshell/.config/quickshell/Utils/CavaColorList.qml b/config/quickshell/.config/quickshell/Utils/CavaColorList.qml deleted file mode 100644 index 3f84207..0000000 --- a/config/quickshell/.config/quickshell/Utils/CavaColorList.qml +++ /dev/null @@ -1,10 +0,0 @@ -import QtQuick -import Quickshell -import qs.Constants -pragma Singleton - -Singleton { - id: root - - readonly property var colorList: [Colors.lavender, Colors.blue, Colors.sapphire, Colors.sky, Colors.teal, Colors.green, Colors.yellow, Colors.peach] -} diff --git a/config/quickshell/.config/quickshell/Utils/FuzzySort.qml b/config/quickshell/.config/quickshell/Utils/FuzzySort.qml new file mode 100644 index 0000000..d729e2a --- /dev/null +++ b/config/quickshell/.config/quickshell/Utils/FuzzySort.qml @@ -0,0 +1,883 @@ +pragma Singleton + +import QtQuick +import Quickshell + +Singleton { + id: root + + // Public API + function go(search, targets, options) { + return _go(search, targets, options); + } + + function single(search, target) { + return _single(search, target); + } + + function highlight(result, open, close) { + if (open === undefined) + open = ''; + if (close === undefined) + close = ''; + return _highlight(result, open, close); + } + + function prepare(target) { + return _prepare(target); + } + + function cleanup() { + return _cleanup(); + } + + // Internal implementation + readonly property var _INFINITY: Infinity + readonly property var _NEGATIVE_INFINITY: -Infinity + readonly property var _NULL: null + property var _noResults: { + let r = []; + r.total = 0; + return r; + } + property var _noTarget: _prepare('') + + property var _preparedCache: new Map() + property var _preparedSearchCache: new Map() + + property var _matchesSimple: [] + property var _matchesStrict: [] + property var _nextBeginningIndexesChanges: [] + property var _keysSpacesBestScores: [] + property var _allowPartialMatchScores: [] + property var _tmpTargets: [] + property var _tmpResults: [] + property var _q: _fastpriorityqueue() + + function _fastpriorityqueue() { + var e = [], o = 0, a = {}; + var v = function (r) { + for (var a = 0, vc = e[a], c = 1; c < o; ) { + var s = c + 1; + a = c; + if (s < o && e[s]._score < e[c]._score) + a = s; + e[a - 1 >> 1] = e[a]; + c = 1 + (a << 1); + } + for (var f = a - 1 >> 1; a > 0 && vc._score < e[f]._score; f = (a = f) - 1 >> 1) + e[a] = e[f]; + e[a] = vc; + }; + a.add = function (r) { + var ac = o; + e[o++] = r; + for (var vc = ac - 1 >> 1; ac > 0 && r._score < e[vc]._score; vc = (ac = vc) - 1 >> 1) + e[ac] = e[vc]; + e[ac] = r; + }; + a.poll = function () { + if (o !== 0) { + var ac = e[0]; + e[0] = e[--o]; + v(); + return ac; + } + }; + a.peek = function () { + if (o !== 0) + return e[0]; + }; + a.replaceTop = function (r) { + e[0] = r; + v(); + }; + return a; + } + + function _createResult() { + return { + target: '', + obj: _NULL, + _score: _NEGATIVE_INFINITY, + _indexes: [], + _targetLower: '', + _targetLowerCodes: _NULL, + _nextBeginningIndexes: _NULL, + _bitflags: 0, + get indexes() { + return this._indexes.slice(0, this._indexes.len).sort((a, b) => a - b); + }, + set indexes(idx) { + this._indexes = idx; + }, + highlight: function (open, close) { + return root._highlight(this, open, close); + }, + get score() { + return root._normalizeScore(this._score); + }, + set score(s) { + this._score = root._denormalizeScore(s); + } + }; + } + + function _createKeysResult(len) { + var arr = new Array(len); + arr._score = _NEGATIVE_INFINITY; + arr.obj = _NULL; + Object.defineProperty(arr, 'score', { + get: function () { + return root._normalizeScore(this._score); + }, + set: function (s) { + this._score = root._denormalizeScore(s); + } + }); + return arr; + } + + function _new_result(target, options) { + var result = _createResult(); + result.target = target; + result.obj = options.obj ?? _NULL; + result._score = options._score ?? _NEGATIVE_INFINITY; + result._indexes = options._indexes ?? []; + result._targetLower = options._targetLower ?? ''; + result._targetLowerCodes = options._targetLowerCodes ?? _NULL; + result._nextBeginningIndexes = options._nextBeginningIndexes ?? _NULL; + result._bitflags = options._bitflags ?? 0; + return result; + } + + function _normalizeScore(score) { + if (score === _NEGATIVE_INFINITY) + return 0; + if (score > 1) + return score; + return Math.E ** (((-score + 1) ** 0.04307 - 1) * -2); + } + + function _denormalizeScore(normalizedScore) { + if (normalizedScore === 0) + return _NEGATIVE_INFINITY; + if (normalizedScore > 1) + return normalizedScore; + return 1 - Math.pow((Math.log(normalizedScore) / -2 + 1), 1 / 0.04307); + } + + function _remove_accents(str) { + return str.replace(/\p{Script=Latin}+/gu, match => match.normalize('NFD')).replace(/[\u0300-\u036f]/g, ''); + } + + function _prepareLowerInfo(str) { + str = _remove_accents(str); + var strLen = str.length; + var lower = str.toLowerCase(); + var lowerCodes = []; + var bitflags = 0; + var containsSpace = false; + + for (var i = 0; i < strLen; ++i) { + var lowerCode = lowerCodes[i] = lower.charCodeAt(i); + if (lowerCode === 32) { + containsSpace = true; + continue; + } + var bit = lowerCode >= 97 && lowerCode <= 122 ? lowerCode - 97 : lowerCode >= 48 && lowerCode <= 57 ? 26 : lowerCode <= 127 ? 30 : 31; + bitflags |= 1 << bit; + } + return { + lowerCodes: lowerCodes, + bitflags: bitflags, + containsSpace: containsSpace, + _lower: lower + }; + } + + function _prepareBeginningIndexes(target) { + var targetLen = target.length; + var beginningIndexes = []; + var beginningIndexesLen = 0; + var wasUpper = false; + var wasAlphanum = false; + for (var i = 0; i < targetLen; ++i) { + var targetCode = target.charCodeAt(i); + var isUpper = targetCode >= 65 && targetCode <= 90; + var isAlphanum = isUpper || targetCode >= 97 && targetCode <= 122 || targetCode >= 48 && targetCode <= 57; + var isBeginning = isUpper && !wasUpper || !wasAlphanum || !isAlphanum; + wasUpper = isUpper; + wasAlphanum = isAlphanum; + if (isBeginning) + beginningIndexes[beginningIndexesLen++] = i; + } + return beginningIndexes; + } + + function _prepareNextBeginningIndexes(target) { + target = _remove_accents(target); + var targetLen = target.length; + var beginningIndexes = _prepareBeginningIndexes(target); + var nextBeginningIndexes = []; + var lastIsBeginning = beginningIndexes[0]; + var lastIsBeginningI = 0; + for (var i = 0; i < targetLen; ++i) { + if (lastIsBeginning > i) { + nextBeginningIndexes[i] = lastIsBeginning; + } else { + lastIsBeginning = beginningIndexes[++lastIsBeginningI]; + nextBeginningIndexes[i] = lastIsBeginning === undefined ? targetLen : lastIsBeginning; + } + } + return nextBeginningIndexes; + } + + function _prepareSearch(search) { + if (typeof search === 'number') + search = '' + search; + else if (typeof search !== 'string') + search = ''; + search = search.trim(); + var info = _prepareLowerInfo(search); + + var spaceSearches = []; + if (info.containsSpace) { + var searches = search.split(/\s+/); + searches = [...new Set(searches)]; + for (var i = 0; i < searches.length; i++) { + if (searches[i] === '') + continue; + var _info = _prepareLowerInfo(searches[i]); + spaceSearches.push({ + lowerCodes: _info.lowerCodes, + _lower: searches[i].toLowerCase(), + containsSpace: false + }); + } + } + return { + lowerCodes: info.lowerCodes, + _lower: info._lower, + containsSpace: info.containsSpace, + bitflags: info.bitflags, + spaceSearches: spaceSearches + }; + } + + function _prepare(target) { + if (typeof target === 'number') + target = '' + target; + else if (typeof target !== 'string') + target = ''; + var info = _prepareLowerInfo(target); + return _new_result(target, { + _targetLower: info._lower, + _targetLowerCodes: info.lowerCodes, + _bitflags: info.bitflags + }); + } + + function _cleanup() { + _preparedCache.clear(); + _preparedSearchCache.clear(); + } + + function _isPrepared(x) { + return typeof x === 'object' && typeof x._bitflags === 'number'; + } + + function _getPrepared(target) { + if (target.length > 999) + return _prepare(target); + var targetPrepared = _preparedCache.get(target); + if (targetPrepared !== undefined) + return targetPrepared; + targetPrepared = _prepare(target); + _preparedCache.set(target, targetPrepared); + return targetPrepared; + } + + function _getPreparedSearch(search) { + if (search.length > 999) + return _prepareSearch(search); + var searchPrepared = _preparedSearchCache.get(search); + if (searchPrepared !== undefined) + return searchPrepared; + searchPrepared = _prepareSearch(search); + _preparedSearchCache.set(search, searchPrepared); + return searchPrepared; + } + + function _getValue(obj, prop) { + var tmp = obj[prop]; + if (tmp !== undefined) + return tmp; + if (typeof prop === 'function') + return prop(obj); + var segs = prop; + if (!Array.isArray(prop)) + segs = prop.split('.'); + var len = segs.length; + var i = -1; + while (obj && (++i < len)) + obj = obj[segs[i]]; + return obj; + } + + function _single(search, target) { + if (!search || !target) + return _NULL; + var preparedSearch = _getPreparedSearch(search); + if (!_isPrepared(target)) + target = _getPrepared(target); + var searchBitflags = preparedSearch.bitflags; + if ((searchBitflags & target._bitflags) !== searchBitflags) + return _NULL; + return _algorithm(preparedSearch, target); + } + + function _highlight(result, open, close) { + if (open === undefined) + open = ''; + if (close === undefined) + close = ''; + var callback = typeof open === 'function' ? open : undefined; + + var target = result.target; + var targetLen = target.length; + var indexes = result.indexes; + var highlighted = ''; + var matchI = 0; + var indexesI = 0; + var opened = false; + var parts = []; + + for (var i = 0; i < targetLen; ++i) { + var ch = target[i]; + if (indexes[indexesI] === i) { + ++indexesI; + if (!opened) { + opened = true; + if (callback) { + parts.push(highlighted); + highlighted = ''; + } else { + highlighted += open; + } + } + if (indexesI === indexes.length) { + if (callback) { + highlighted += ch; + parts.push(callback(highlighted, matchI++)); + highlighted = ''; + parts.push(target.substr(i + 1)); + } else { + highlighted += ch + close + target.substr(i + 1); + } + break; + } + } else { + if (opened) { + opened = false; + if (callback) { + parts.push(callback(highlighted, matchI++)); + highlighted = ''; + } else { + highlighted += close; + } + } + } + highlighted += ch; + } + return callback ? parts : highlighted; + } + + function _all(targets, options) { + var results = []; + results.total = targets.length; + var limit = options?.limit || _INFINITY; + + if (options?.key) { + for (var i = 0; i < targets.length; i++) { + var obj = targets[i]; + var target = _getValue(obj, options.key); + if (target == _NULL) + continue; + if (!_isPrepared(target)) + target = _getPrepared(target); + var result = _new_result(target.target, { + _score: target._score, + obj: obj + }); + results.push(result); + if (results.length >= limit) + return results; + } + } else if (options?.keys) { + for (var i = 0; i < targets.length; i++) { + var obj = targets[i]; + var objResults = _createKeysResult(options.keys.length); + for (var keyI = options.keys.length - 1; keyI >= 0; --keyI) { + var target = _getValue(obj, options.keys[keyI]); + if (!target) { + objResults[keyI] = _noTarget; + continue; + } + if (!_isPrepared(target)) + target = _getPrepared(target); + target._score = _NEGATIVE_INFINITY; + target._indexes.len = 0; + objResults[keyI] = target; + } + objResults.obj = obj; + objResults._score = _NEGATIVE_INFINITY; + results.push(objResults); + if (results.length >= limit) + return results; + } + } else { + for (var i = 0; i < targets.length; i++) { + var target = targets[i]; + if (target == _NULL) + continue; + if (!_isPrepared(target)) + target = _getPrepared(target); + target._score = _NEGATIVE_INFINITY; + target._indexes.len = 0; + results.push(target); + if (results.length >= limit) + return results; + } + } + return results; + } + + function _algorithm(preparedSearch, prepared, allowSpaces, allowPartialMatch) { + if (allowSpaces === undefined) + allowSpaces = false; + if (allowPartialMatch === undefined) + allowPartialMatch = false; + + if (allowSpaces === false && preparedSearch.containsSpace) + return _algorithmSpaces(preparedSearch, prepared, allowPartialMatch); + + var searchLower = preparedSearch._lower; + var searchLowerCodes = preparedSearch.lowerCodes; + var searchLowerCode = searchLowerCodes[0]; + var targetLowerCodes = prepared._targetLowerCodes; + var searchLen = searchLowerCodes.length; + var targetLen = targetLowerCodes.length; + var searchI = 0; + var targetI = 0; + var matchesSimpleLen = 0; + + for (; ; ) { + var isMatch = searchLowerCode === targetLowerCodes[targetI]; + if (isMatch) { + _matchesSimple[matchesSimpleLen++] = targetI; + ++searchI; + if (searchI === searchLen) + break; + searchLowerCode = searchLowerCodes[searchI]; + } + ++targetI; + if (targetI >= targetLen) + return _NULL; + } + + searchI = 0; + var successStrict = false; + var matchesStrictLen = 0; + + var nextBeginningIndexes = prepared._nextBeginningIndexes; + if (nextBeginningIndexes === _NULL) + nextBeginningIndexes = prepared._nextBeginningIndexes = _prepareNextBeginningIndexes(prepared.target); + targetI = _matchesSimple[0] === 0 ? 0 : nextBeginningIndexes[_matchesSimple[0] - 1]; + + var backtrackCount = 0; + if (targetI !== targetLen) + for (; ; ) { + if (targetI >= targetLen) { + if (searchI <= 0) + break; + ++backtrackCount; + if (backtrackCount > 200) + break; + --searchI; + var lastMatch = _matchesStrict[--matchesStrictLen]; + targetI = nextBeginningIndexes[lastMatch]; + } else { + var isMatch = searchLowerCodes[searchI] === targetLowerCodes[targetI]; + if (isMatch) { + _matchesStrict[matchesStrictLen++] = targetI; + ++searchI; + if (searchI === searchLen) { + successStrict = true; + break; + } + ++targetI; + } else { + targetI = nextBeginningIndexes[targetI]; + } + } + } + + var substringIndex = searchLen <= 1 ? -1 : prepared._targetLower.indexOf(searchLower, _matchesSimple[0]); + var isSubstring = !!~substringIndex; + var isSubstringBeginning = !isSubstring ? false : substringIndex === 0 || prepared._nextBeginningIndexes[substringIndex - 1] === substringIndex; + + if (isSubstring && !isSubstringBeginning) { + for (var i = 0; i < nextBeginningIndexes.length; i = nextBeginningIndexes[i]) { + if (i <= substringIndex) + continue; + for (var s = 0; s < searchLen; s++) + if (searchLowerCodes[s] !== prepared._targetLowerCodes[i + s]) + break; + if (s === searchLen) { + substringIndex = i; + isSubstringBeginning = true; + break; + } + } + } + + var calculateScore = function (matches) { + var score = 0; + var extraMatchGroupCount = 0; + for (var i = 1; i < searchLen; ++i) { + if (matches[i] - matches[i - 1] !== 1) { + score -= matches[i]; + ++extraMatchGroupCount; + } + } + var unmatchedDistance = matches[searchLen - 1] - matches[0] - (searchLen - 1); + score -= (12 + unmatchedDistance) * extraMatchGroupCount; + if (matches[0] !== 0) + score -= matches[0] * matches[0] * 0.2; + if (!successStrict) { + score *= 1000; + } else { + var uniqueBeginningIndexes = 1; + for (var i = nextBeginningIndexes[0]; i < targetLen; i = nextBeginningIndexes[i]) + ++uniqueBeginningIndexes; + if (uniqueBeginningIndexes > 24) + score *= (uniqueBeginningIndexes - 24) * 10; + } + score -= (targetLen - searchLen) / 2; + if (isSubstring) + score /= 1 + searchLen * searchLen * 1; + if (isSubstringBeginning) + score /= 1 + searchLen * searchLen * 1; + score -= (targetLen - searchLen) / 2; + return score; + }; + + var matchesBest, score; + if (!successStrict) { + if (isSubstring) + for (var i = 0; i < searchLen; ++i) + _matchesSimple[i] = substringIndex + i; + matchesBest = _matchesSimple; + score = calculateScore(matchesBest); + } else { + if (isSubstringBeginning) { + for (var i = 0; i < searchLen; ++i) + _matchesSimple[i] = substringIndex + i; + matchesBest = _matchesSimple; + score = calculateScore(_matchesSimple); + } else { + matchesBest = _matchesStrict; + score = calculateScore(_matchesStrict); + } + } + + prepared._score = score; + for (var i = 0; i < searchLen; ++i) + prepared._indexes[i] = matchesBest[i]; + prepared._indexes.len = searchLen; + + var result = _createResult(); + result.target = prepared.target; + result._score = prepared._score; + result._indexes = prepared._indexes; + return result; + } + + function _algorithmSpaces(preparedSearch, target, allowPartialMatch) { + var seen_indexes = new Set(); + var score = 0; + var result = _NULL; + + var first_seen_index_last_search = 0; + var searches = preparedSearch.spaceSearches; + var searchesLen = searches.length; + var changeslen = 0; + + var resetNextBeginningIndexes = function () { + for (let i = changeslen - 1; i >= 0; i--) + target._nextBeginningIndexes[_nextBeginningIndexesChanges[i * 2 + 0]] = _nextBeginningIndexesChanges[i * 2 + 1]; + }; + + var hasAtLeast1Match = false; + for (var i = 0; i < searchesLen; ++i) { + _allowPartialMatchScores[i] = _NEGATIVE_INFINITY; + var search = searches[i]; + result = _algorithm(search, target); + + if (allowPartialMatch) { + if (result === _NULL) + continue; + hasAtLeast1Match = true; + } else { + if (result === _NULL) { + resetNextBeginningIndexes(); + return _NULL; + } + } + + var isTheLastSearch = i === searchesLen - 1; + if (!isTheLastSearch) { + var indexes = result._indexes; + var indexesIsConsecutiveSubstring = true; + for (let j = 0; j < indexes.len - 1; j++) { + if (indexes[j + 1] - indexes[j] !== 1) { + indexesIsConsecutiveSubstring = false; + break; + } + } + + if (indexesIsConsecutiveSubstring) { + var newBeginningIndex = indexes[indexes.len - 1] + 1; + var toReplace = target._nextBeginningIndexes[newBeginningIndex - 1]; + for (let j = newBeginningIndex - 1; j >= 0; j--) { + if (toReplace !== target._nextBeginningIndexes[j]) + break; + target._nextBeginningIndexes[j] = newBeginningIndex; + _nextBeginningIndexesChanges[changeslen * 2 + 0] = j; + _nextBeginningIndexesChanges[changeslen * 2 + 1] = toReplace; + changeslen++; + } + } + } + + score += result._score / searchesLen; + _allowPartialMatchScores[i] = result._score / searchesLen; + + if (result._indexes[0] < first_seen_index_last_search) { + score -= (first_seen_index_last_search - result._indexes[0]) * 2; + } + first_seen_index_last_search = result._indexes[0]; + + for (var j = 0; j < result._indexes.len; ++j) + seen_indexes.add(result._indexes[j]); + } + + if (allowPartialMatch && !hasAtLeast1Match) + return _NULL; + + resetNextBeginningIndexes(); + + var allowSpacesResult = _algorithm(preparedSearch, target, true); + if (allowSpacesResult !== _NULL && allowSpacesResult._score > score) { + if (allowPartialMatch) { + for (var i = 0; i < searchesLen; ++i) { + _allowPartialMatchScores[i] = allowSpacesResult._score / searchesLen; + } + } + return allowSpacesResult; + } + + if (allowPartialMatch) + result = target; + result._score = score; + + var idx = 0; + for (let index of seen_indexes) + result._indexes[idx++] = index; + result._indexes.len = idx; + + return result; + } + + function _go(search, targets, options) { + if (!search) + return options?.all ? _all(targets, options) : _noResults; + + var preparedSearch = _getPreparedSearch(search); + var searchBitflags = preparedSearch.bitflags; + var containsSpace = preparedSearch.containsSpace; + + var threshold = _denormalizeScore(options?.threshold ?? 0.35); + var limit = options?.limit || _INFINITY; + + var resultsLen = 0; + var limitedCount = 0; + var targetsLen = targets.length; + + function push_result(result) { + if (resultsLen < limit) { + _q.add(result); + ++resultsLen; + } else { + ++limitedCount; + if (result._score > _q.peek()._score) + _q.replaceTop(result); + } + } + + if (options?.key) { + var key = options.key; + for (var i = 0; i < targetsLen; ++i) { + var obj = targets[i]; + var target = _getValue(obj, key); + if (!target) + continue; + if (!_isPrepared(target)) + target = _getPrepared(target); + if ((searchBitflags & target._bitflags) !== searchBitflags) + continue; + var result = _algorithm(preparedSearch, target); + if (result === _NULL) + continue; + if (result._score < threshold) + continue; + result.obj = obj; + push_result(result); + } + } else if (options?.keys) { + var keys = options.keys; + var keysLen = keys.length; + + outer: for (var i = 0; i < targetsLen; ++i) { + var obj = targets[i]; + var keysBitflags = 0; + for (var keyI = 0; keyI < keysLen; ++keyI) { + var key = keys[keyI]; + var target = _getValue(obj, key); + if (!target) { + _tmpTargets[keyI] = _noTarget; + continue; + } + if (!_isPrepared(target)) + target = _getPrepared(target); + _tmpTargets[keyI] = target; + keysBitflags |= target._bitflags; + } + + if ((searchBitflags & keysBitflags) !== searchBitflags) + continue; + + if (containsSpace) + for (let j = 0; j < preparedSearch.spaceSearches.length; j++) + _keysSpacesBestScores[j] = _NEGATIVE_INFINITY; + + for (var keyI = 0; keyI < keysLen; ++keyI) { + target = _tmpTargets[keyI]; + if (target === _noTarget) { + _tmpResults[keyI] = _noTarget; + continue; + } + + _tmpResults[keyI] = _algorithm(preparedSearch, target, false, containsSpace); + if (_tmpResults[keyI] === _NULL) { + _tmpResults[keyI] = _noTarget; + continue; + } + + if (containsSpace) + for (let j = 0; j < preparedSearch.spaceSearches.length; j++) { + if (_allowPartialMatchScores[j] > -1000) { + if (_keysSpacesBestScores[j] > _NEGATIVE_INFINITY) { + var tmp = (_keysSpacesBestScores[j] + _allowPartialMatchScores[j]) / 4; + if (tmp > _keysSpacesBestScores[j]) + _keysSpacesBestScores[j] = tmp; + } + } + if (_allowPartialMatchScores[j] > _keysSpacesBestScores[j]) + _keysSpacesBestScores[j] = _allowPartialMatchScores[j]; + } + } + + if (containsSpace) { + for (let j = 0; j < preparedSearch.spaceSearches.length; j++) + if (_keysSpacesBestScores[j] === _NEGATIVE_INFINITY) + continue outer; + } else { + var hasAtLeast1Match = false; + for (let j = 0; j < keysLen; j++) + if (_tmpResults[j]._score !== _NEGATIVE_INFINITY) { + hasAtLeast1Match = true; + break; + } + if (!hasAtLeast1Match) + continue; + } + + var objResults = _createKeysResult(keysLen); + for (let j = 0; j < keysLen; j++) + objResults[j] = _tmpResults[j]; + + var score; + if (containsSpace) { + score = 0; + for (let j = 0; j < preparedSearch.spaceSearches.length; j++) + score += _keysSpacesBestScores[j]; + } else { + score = _NEGATIVE_INFINITY; + for (let j = 0; j < keysLen; j++) { + var res = objResults[j]; + if (res._score > -1000) { + if (score > _NEGATIVE_INFINITY) { + var tmp = (score + res._score) / 4; + if (tmp > score) + score = tmp; + } + } + if (res._score > score) + score = res._score; + } + } + + objResults.obj = obj; + objResults._score = score; + + if (options?.scoreFn) { + score = options.scoreFn(objResults); + if (!score) + continue; + score = _denormalizeScore(score); + objResults._score = score; + } + + if (score < threshold) + continue; + push_result(objResults); + } + } else { + for (var i = 0; i < targetsLen; ++i) { + var target = targets[i]; + if (!target) + continue; + if (!_isPrepared(target)) + target = _getPrepared(target); + if ((searchBitflags & target._bitflags) !== searchBitflags) + continue; + var result = _algorithm(preparedSearch, target); + if (result === _NULL) + continue; + if (result._score < threshold) + continue; + push_result(result); + } + } + + if (resultsLen === 0) + return _noResults; + var results = new Array(resultsLen); + for (var i = resultsLen - 1; i >= 0; --i) + results[i] = _q.poll(); + results.total = resultsLen + limitedCount; + return results; + } +} diff --git a/config/quickshell/.config/quickshell/Utils/Logger.qml b/config/quickshell/.config/quickshell/Utils/Logger.qml index 063e488..7f14f62 100644 --- a/config/quickshell/.config/quickshell/Utils/Logger.qml +++ b/config/quickshell/.config/quickshell/Utils/Logger.qml @@ -1,58 +1,65 @@ pragma Singleton import Quickshell -import qs.Utils Singleton { id: root function _formatMessage(...args) { - var t = Time.getFormattedTimestamp() if (args.length > 1) { - const maxLength = 14 - var module = args.shift().substring(0, maxLength).padStart(maxLength, " ") - return `\x1b[36m[${t}]\x1b[0m \x1b[35m${module}\x1b[0m ` + args.join(" ") + const maxLength = 14; + var module = args.shift().substring(0, maxLength).padStart(maxLength, " "); + return `\x1b[35m${module}\x1b[0m ` + args.join(" "); } else { - return `[\x1b[36m[${t}]\x1b[0m ` + args.join(" ") + return args.join(" "); } } function _getStackTrace() { try { - throw new Error("Stack trace") + throw new Error("Stack trace"); } catch (e) { - return e.stack + return e.stack; } } - function log(...args) { - var msg = _formatMessage(...args) - console.log(msg) + // Debug log (only when Settings.isDebug is true) + function d(...args) { + var msg = _formatMessage(...args); + console.debug(msg); } - function warn(...args) { - var msg = _formatMessage(...args) - console.warn(msg) + // Info log (always visible) + function i(...args) { + var msg = _formatMessage(...args); + console.info(msg); } - function error(...args) { - var msg = _formatMessage(...args) - console.error(msg) + // Warning log (always visible) + function w(...args) { + var msg = _formatMessage(...args); + console.warn(msg); + } + + // Error log (always visible) + function e(...args) { + var msg = _formatMessage(...args); + console.error(msg); } function callStack() { - var stack = _getStackTrace() - Logger.log("Debug", "--------------------------") - Logger.log("Debug", "Current call stack") + var stack = _getStackTrace(); + Logger.i("Debug", "--------------------------"); + Logger.i("Debug", "Current call stack"); // Split the stack into lines and log each one - var stackLines = stack.split('\n') + var stackLines = stack.split('\n'); for (var i = 0; i < stackLines.length; i++) { - var line = stackLines[i].trim() // Remove leading/trailing whitespace + var line = stackLines[i].trim(); // Remove leading/trailing whitespace if (line.length > 0) { // Only log non-empty lines - Logger.log("Debug", `- ${line}`) + Logger.i("Debug", `- ${line}`); } } - Logger.log("Debug", "--------------------------") + Logger.i("Debug", "--------------------------"); } } diff --git a/config/quickshell/.config/quickshell/Utils/Time.qml b/config/quickshell/.config/quickshell/Utils/Time.qml index 1cd3d08..6c13123 100644 --- a/config/quickshell/.config/quickshell/Utils/Time.qml +++ b/config/quickshell/.config/quickshell/Utils/Time.qml @@ -6,11 +6,25 @@ Singleton { id: root // Current date - property var date: new Date() + property var now: new Date() + // Unix timestamp of the last update + property real _lastUpdateTs: Date.now() // Returns a Unix Timestamp (in seconds) readonly property int timestamp: { - return Math.floor(date / 1000); + return Math.floor(root.now / 1000); } + // Timer state (for countdown/stopwatch) + property bool timerRunning: false + property bool timerStopwatchMode: false + property int timerRemainingSeconds: 0 + property int timerTotalSeconds: 0 + property int timerElapsedSeconds: 0 + property bool timerSoundPlaying: false + property int timerStartTimestamp: 0 // Unix timestamp when timer was started + property int timerPausedAt: 0 // Value when paused (for resuming) + + // Signal emitted when a significant time jump is detected (e.g. system resume) + signal resumed() // Formats a Date object into a YYYYMMDD-HHMMSS string. function getFormattedTimestamp(date) { @@ -27,7 +41,7 @@ Singleton { return `${year}${month}${day}-${hours}${minutes}${seconds}`; } - // Format an easy to read approximate duration ex: 4h32m + // Format an easy to read approximate duration ex: 4h 32m // Used to display the time remaining on the Battery widget, computer uptime, etc.. function formatVagueHumanReadableDuration(totalSeconds) { if (typeof totalSeconds !== 'number' || totalSeconds < 0) @@ -53,7 +67,7 @@ Singleton { if (!hours && !minutes) parts.push(`${seconds}s`); - return parts.join(''); + return parts.join(' '); } // Format a date into @@ -63,22 +77,74 @@ Singleton { const diff = Date.now() - date.getTime(); if (diff < 60000) - return "now"; + return "Just now"; + + if (diff < 120000) + return "1 minute ago"; if (diff < 3.6e+06) - return `${Math.floor(diff / 60000)}m ago`; + return `${Math.floor(diff / 60000)} minutes ago`; + + if (diff < 7.2e+06) + return "1 hour ago"; if (diff < 8.64e+07) - return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 3.6e+06)} hours ago`; - return `${Math.floor(diff / 86400000)}d ago`; + if (diff < 1.728e+08) + return "1 day ago"; + + return `${Math.floor(diff / 8.64e+07)} days ago`; + } + + Component.onCompleted: { + // Start by syncing to the next second boundary + var now = new Date(); + var msUntilNextSecond = 1000 - now.getMilliseconds(); + updateTimer.interval = msUntilNextSecond + 10; // +10ms buffer + updateTimer.restart(); } Timer { + id: updateTimer + interval: 1000 repeat: true running: true - onTriggered: root.date = new Date() + triggeredOnStart: false + onTriggered: { + var newTime = new Date(); + var currentTs = newTime.getTime(); + // Detect time jump (e.g. system resume) - threshold: 5 seconds + if (currentTs - root._lastUpdateTs > 5000) { + Logger.i("Time", "Time jump detected (" + Math.round((currentTs - root._lastUpdateTs) / 1000) + "s) - likely system resume"); + root.resumed(); + } + root._lastUpdateTs = currentTs; + root.now = newTime; + // Update timer if running + if (root.timerRunning && root.timerStartTimestamp > 0) { + const elapsedSinceStart = root.timestamp - root.timerStartTimestamp; + if (root.timerStopwatchMode) { + root.timerElapsedSeconds = root.timerPausedAt + elapsedSinceStart; + } else { + root.timerRemainingSeconds = root.timerTotalSeconds - elapsedSinceStart; + if (root.timerRemainingSeconds <= 0) + root.timerOnFinished(); + + } + } + // Adjust next interval to sync with the start of the next second + var msIntoSecond = newTime.getMilliseconds(); + if (msIntoSecond > 100) { + // If we're more than 100ms into the second, adjust for next time + updateTimer.interval = 1000 - msIntoSecond + 10; + // +10ms buffer + updateTimer.restart(); + } else { + updateTimer.interval = 1000; + } + } } } diff --git a/config/quickshell/.config/quickshell/apply-color b/config/quickshell/.config/quickshell/apply-color index 1958de4..2381631 100755 --- a/config/quickshell/.config/quickshell/apply-color +++ b/config/quickshell/.config/quickshell/apply-color @@ -6,14 +6,7 @@ } . "$HOME/.local/snippets/apply-color-helper" -if pgrep -x "quickshell" -u "$USER" >/dev/null; then - qs ipc call colors setPrimary ${colorHex} || { - log_error "Failed to send IPC command to quickshell" - exit 1 - } +qs ipc call colors setColor mPrimary "$colorHex" - log_success "quickshell" -else - log_error "quickshell is not running. Cannot apply color." - exit 1 -fi + +log_success "quickshell" diff --git a/config/quickshell/.config/quickshell/shell.qml b/config/quickshell/.config/quickshell/shell.qml index 735ad3e..f1f6b0c 100644 --- a/config/quickshell/.config/quickshell/shell.qml +++ b/config/quickshell/.config/quickshell/shell.qml @@ -1,28 +1,27 @@ import QtQuick import Quickshell import Quickshell.Widgets -import qs.Constants +import qs.Modules.Background import qs.Modules.Bar import qs.Modules.Misc -import qs.Modules.Panel +import qs.Modules.Sidebar import qs.Services ShellRoot { id: root + Component.onCompleted: { + ImageCacheService.init(); + } + Loader { id: loader - active: CacheService.loaded && NukeKded6.done + active: Init.loaded && NukeKded6.done && ImageCacheService.initialized && ShellState.isLoaded sourceComponent: Item { Component.onCompleted: { SunsetService; - Niri.onScreenshotCaptured = Screenshot.onScreenshotCaptured; - } - - Notification { - id: notification } IPCService { @@ -37,34 +36,16 @@ ShellRoot { id: corners } - CalendarPanel { - id: calendarPanel - - objectName: "calendarPanel" + Sidebars { + id: sidebars } - ControlCenterPanel { - id: controlCenterPanel - - objectName: "controlCenterPanel" + Notification { + id: notification } - NotificationHistoryPanel { - id: notificationHistoryPanel - - objectName: "notificationHistoryPanel" - } - - WiFiPanel { - id: wifiPanel - - objectName: "wifiPanel" - } - - BluetoothPanel { - id: bluetoothPanel - - objectName: "bluetoothPanel" + Background { + id: background } } diff --git a/config/wallpaper/.config/wallreel/config.json b/config/wallpaper/.config/wallreel/config.json index bdc19c0..cfea8b4 100644 --- a/config/wallpaper/.config/wallreel/config.json +++ b/config/wallpaper/.config/wallreel/config.json @@ -1,39 +1,30 @@ { - "$schema": "https://raw.githubusercontent.com/Uyanide/WallReel/refs/heads/master/config.schema.json", - "wallpaper": { - "dirs": [ - { - "path": "~/Pictures/backgrounds" - }, - { - "path": "/media/Beta/壁纸/库" - } - ], - "excludes": [ - "nao-stars-crop-adjust-flop.jpg", - "miku-gate.jpg", - "\\.md$" - ] - }, - "action": { - "onSelected": "change-wallpaper '{{ path }}' 2560 1440 --skip-colortheme; change-colortheme -c '{{ colorHex }}'", - "onPreview": "change-colortheme -c '{{ colorHex }}' niri quickshell; swww img -n background \"{{ path }}\" --transition-type fade --transition-duration 0.5", - "quitOnSelected": true, - "saveState": [ - { - "key": "flavor", - "fallback": "#89b4fa", - "command": "cat ~/.config/posh_theme.omp.json | jq -r .blocks[0].segments[0].foreground" - }, - { - "key": "wallpaper", - "fallback": "$HOME/Pictures/backgrounds/miku-space.jpg", - "command": "find ~/.local/share/wallpaper/current -type f | head -n 1" - } - ], - "onRestore": "change-colortheme -c '{{ flavor }}' niri quickshell; swww img -n background \"{{ wallpaper }}\" --transition-type fade --transition-duration 0.5" - }, - "cache": { - "maxImageEntries": 300 - } + "$schema": "https://raw.githubusercontent.com/Uyanide/WallReel/refs/heads/master/config.schema.json", + "wallpaper": { + "dirs": [ + { + "path": "~/Pictures/backgrounds" + }, + { + "path": "/media/Beta/壁纸/库" + } + ], + "excludes": ["nao-stars-crop-adjust-flop.jpg", "miku-gate.jpg", "\\.md$"] + }, + "action": { + "onSelected": "qs ipc call background setWallpaper '{{ path }}'; qs ipc call colors setColor mPrimary '{{ colorHex }}'", + "onPreview": "qs ipc call background previewWallpaper '{{ path }}'; change-colortheme -c '{{ colorHex }}' quickshell niri", + "quitOnSelected": true, + "saveState": [ + { + "key": "flavor", + "fallback": "#89b4fa", + "command": "qs ipc call colors getColor mPrimary" + } + ], + "onRestore": "qs ipc call background previewWallpaper ''; change-colortheme -c '{{ flavor }}' quickshell niri" + }, + "cache": { + "maxImageEntries": 300 + } } diff --git a/config/yazi/.config/yazi/package.toml b/config/yazi/.config/yazi/package.toml index 513851b..27d8683 100644 --- a/config/yazi/.config/yazi/package.toml +++ b/config/yazi/.config/yazi/package.toml @@ -1,11 +1,11 @@ [[plugin.deps]] use = "yazi-rs/plugins:git" -rev = "b224ddf" -hash = "270915fa8282a19908449530ff66f7e2" +rev = "1962818" +hash = "26db011a778f261d730d4f5f8bf24b3f" [[plugin.deps]] use = "yazi-rs/plugins:smart-enter" -rev = "b224ddf" +rev = "1962818" hash = "187cc58ba7ac3befd49c342129e6f1b6" [[plugin.deps]] diff --git a/config/yazi/.config/yazi/plugins/git.yazi/main.lua b/config/yazi/.config/yazi/plugins/git.yazi/main.lua index 993be7e..dcb0ce1 100644 --- a/config/yazi/.config/yazi/plugins/git.yazi/main.lua +++ b/config/yazi/.config/yazi/plugins/git.yazi/main.lua @@ -1,4 +1,4 @@ ---- @since 25.12.29 +--- @since 26.1.22 local WINDOWS = ya.target_family() == "windows" @@ -224,7 +224,6 @@ local function fetch(_, job) :cwd(tostring(cwd)) :arg({ "--no-optional-locks", "-c", "core.quotePath=", "status", "--porcelain", "-unormal", "--no-renames", "--ignored=matching" }) :arg(paths) - :stdout(Command.PIPED) :output() if not output then return true, Err("Cannot spawn `git` command, error: %s", err)