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)