From 5bc8f410e77ec71cb69728d0459dd6779244f749 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 09:32:02 -0400 Subject: [PATCH 01/15] Network/Wi-Fi: smarter logging to avoid flood --- Modules/Bar/Widgets/WiFi.qml | 2 +- Services/NetworkService.qml | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Modules/Bar/Widgets/WiFi.qml b/Modules/Bar/Widgets/WiFi.qml index 2b06c6f..cf5ddba 100644 --- a/Modules/Bar/Widgets/WiFi.qml +++ b/Modules/Bar/Widgets/WiFi.qml @@ -30,7 +30,7 @@ NIconButton { icon: { try { - if (NetworkService.ethernet) { + if (NetworkService.ethernetConnected) { return "lan" } let connected = false diff --git a/Services/NetworkService.qml b/Services/NetworkService.qml index 21614f2..91fbca9 100644 --- a/Services/NetworkService.qml +++ b/Services/NetworkService.qml @@ -14,7 +14,7 @@ Singleton { property bool connecting: false property string connectingTo: "" property string lastError: "" - property bool ethernet: false + property bool ethernetConnected: false // Persistent cache property string cacheFile: Settings.cacheDir + "network.json" @@ -169,12 +169,14 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - - root.ethernet = text.split("\n").some(line => { + const connected = text.split("\n").some(line => { const parts = line.split(":") return parts[1] === "ethernet" && parts[2] === "connected" }) - Logger.log("Network", "Ethernet connected:", root.ethernet) + if (root.ethernetConnected !== connected) { + root.ethernetConnected = connected + Logger.log("Network", "Ethernet connected:", root.ethernetConnected) + } } } } @@ -286,9 +288,12 @@ Singleton { } } + if (JSON.stringify(root.networks) !== JSON.stringify(nets)) { + Logger.log("Network", "Discovered", Object.keys(nets).length, "Wi-Fi networks") + } root.networks = nets root.scanning = false - Logger.log("Network", "Discovered", Object.keys(root.networks).length, "Wi-Fi networks") + } } From fc1ee9fb2fae032587f840d466010c6069b69ceb Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 13:03:22 -0400 Subject: [PATCH 02/15] Network/WiFi: improve UI with more immediate feedback on operations. + proper deletion of profiles when forgetting a network --- Modules/WiFiPanel/WiFiPanel.qml | 63 +++++++++++- Services/NetworkService.qml | 170 ++++++++++++++++++++++++++++---- 2 files changed, 210 insertions(+), 23 deletions(-) diff --git a/Modules/WiFiPanel/WiFiPanel.qml b/Modules/WiFiPanel/WiFiPanel.qml index 4b8b904..c6c2427 100644 --- a/Modules/WiFiPanel/WiFiPanel.qml +++ b/Modules/WiFiPanel/WiFiPanel.qml @@ -215,11 +215,23 @@ NPanel { Layout.fillWidth: true implicitHeight: netColumn.implicitHeight + (Style.marginM * scaling * 2) radius: Style.radiusM * scaling + + // Add opacity for operations in progress + opacity: (NetworkService.disconnectingFrom === modelData.ssid + || NetworkService.forgettingNetwork === modelData.ssid) ? 0.6 : 1.0 + color: modelData.connected ? Qt.rgba(Color.mPrimary.r, Color.mPrimary.g, Color.mPrimary.b, 0.05) : Color.mSurface border.width: Math.max(1, Style.borderS * scaling) border.color: modelData.connected ? Color.mPrimary : Color.mOutline + // Smooth opacity animation + Behavior on opacity { + NumberAnimation { + duration: Style.animationNormal + } + } + ColumnLayout { id: netColumn width: parent.width - (Style.marginM * scaling * 2) @@ -276,8 +288,9 @@ NPanel { Layout.preferredWidth: Style.marginXXS * scaling } + // Update the status badges area (around line 237) Rectangle { - visible: modelData.connected + visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid color: Color.mPrimary radius: height * 0.5 width: connectedText.implicitWidth + (Style.marginS * scaling * 2) @@ -292,8 +305,42 @@ NPanel { } } + Rectangle { + visible: NetworkService.disconnectingFrom === modelData.ssid + color: Color.mError + radius: height * 0.5 + width: disconnectingText.implicitWidth + (Style.marginS * scaling * 2) + height: disconnectingText.implicitHeight + (Style.marginXXS * scaling * 2) + + NText { + id: disconnectingText + anchors.centerIn: parent + text: "Disconnecting..." + font.pointSize: Style.fontSizeXXS * scaling + color: Color.mOnPrimary + } + } + + Rectangle { + visible: NetworkService.forgettingNetwork === modelData.ssid + color: Color.mError + radius: height * 0.5 + width: forgettingText.implicitWidth + (Style.marginS * scaling * 2) + height: forgettingText.implicitHeight + (Style.marginXXS * scaling * 2) + + NText { + id: forgettingText + anchors.centerIn: parent + text: "Forgetting..." + font.pointSize: Style.fontSizeXXS * scaling + 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 * scaling) @@ -318,6 +365,8 @@ NPanel { NBusyIndicator { visible: NetworkService.connectingTo === modelData.ssid + || NetworkService.disconnectingFrom === modelData.ssid + || NetworkService.forgettingNetwork === modelData.ssid running: visible color: Color.mPrimary size: Style.baseWidgetSize * 0.5 * scaling @@ -326,6 +375,8 @@ NPanel { NIconButton { visible: (modelData.existing || modelData.cached) && !modelData.connected && NetworkService.connectingTo !== modelData.ssid + && NetworkService.forgettingNetwork !== modelData.ssid + && NetworkService.disconnectingFrom !== modelData.ssid icon: "delete" tooltipText: "Forget network" sizeRatio: 0.7 @@ -335,6 +386,8 @@ NPanel { 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" @@ -356,7 +409,7 @@ NPanel { } NButton { - visible: modelData.connected + visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid text: "Disconnect" outlined: !hovered fontSize: Style.fontSizeXS * scaling @@ -368,7 +421,8 @@ NPanel { // Password input Rectangle { - visible: passwordSsid === modelData.ssid + visible: passwordSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + && NetworkService.forgettingNetwork !== modelData.ssid Layout.fillWidth: true height: passwordRow.implicitHeight + Style.marginS * scaling * 2 color: Color.mSurfaceVariant @@ -449,7 +503,8 @@ NPanel { // Forget network Rectangle { - visible: expandedSsid === modelData.ssid + visible: expandedSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid + && NetworkService.forgettingNetwork !== modelData.ssid Layout.fillWidth: true height: forgetRow.implicitHeight + Style.marginS * 2 * scaling color: Color.mSurfaceVariant diff --git a/Services/NetworkService.qml b/Services/NetworkService.qml index 91fbca9..f46e12a 100644 --- a/Services/NetworkService.qml +++ b/Services/NetworkService.qml @@ -15,6 +15,8 @@ Singleton { property string connectingTo: "" property string lastError: "" property bool ethernetConnected: false + property string disconnectingFrom: "" + property string forgettingNetwork: "" // Persistent cache property string cacheFile: Settings.cacheDir + "network.json" @@ -123,11 +125,14 @@ Singleton { } function disconnect(ssid) { + disconnectingFrom = ssid disconnectProcess.ssid = ssid disconnectProcess.running = true } function forget(ssid) { + forgettingNetwork = ssid + // Remove from cache let known = cacheAdapter.knownNetworks delete known[ssid] @@ -144,6 +149,40 @@ Singleton { forgetProcess.running = true } + // Helper function to immediately update network status + function updateNetworkStatus(ssid, connected) { + let nets = networks + + // Update all networks connected status + for (let key in nets) { + if (nets[key].connected && key !== ssid) { + nets[key].connected = false + } + } + + // Update the target network if it exists + if (nets[ssid]) { + nets[ssid].connected = connected + nets[ssid].existing = true + nets[ssid].cached = true + } else if (connected) { + // Create a temporary entry if network doesn't exist yet + nets[ssid] = { + "ssid": ssid, + "security": "--", + "signal": 100, + "connected"// Default to good signal until real scan + : true, + "existing": true, + "cached": true + } + } + + // Trigger property change notification + networks = ({}) + networks = nets + } + // Helper functions function signalIcon(signal) { if (signal >= 80) @@ -170,9 +209,9 @@ Singleton { stdout: StdioCollector { onStreamFinished: { const connected = text.split("\n").some(line => { - const parts = line.split(":") - return parts[1] === "ethernet" && parts[2] === "connected" - }) + const parts = line.split(":") + return parts[1] === "ethernet" && parts[2] === "connected" + }) if (root.ethernetConnected !== connected) { root.ethernetConnected = connected Logger.log("Network", "Ethernet connected:", root.ethernetConnected) @@ -189,7 +228,7 @@ Singleton { stdout: StdioCollector { onStreamFinished: { const enabled = text.trim() === "enabled" - Logger.log("Network", "Wifi enabled:", enabled) + Logger.log("Network", "Wi-Fi enabled:", enabled) if (Settings.data.network.wifiEnabled !== enabled) { Settings.data.network.wifiEnabled = enabled } @@ -229,11 +268,11 @@ Singleton { id: scanProcess running: false command: ["sh", "-c", ` - # Get existing profiles - profiles=$(nmcli -t -f NAME,TYPE connection show | grep ':802-11-wireless' | cut -d: -f1) + # Get list of saved connection profiles (just the names) + profiles=$(nmcli -t -f NAME connection show | tr '\n' '|') # Get WiFi networks - nmcli -t -f SSID,SECURITY,SIGNAL,IN-USE device wifi list | while read line; do + nmcli -t -f SSID,SECURITY,SIGNAL,IN-USE device wifi list --rescan yes | while read line; do ssid=$(echo "$line" | cut -d: -f1) security=$(echo "$line" | cut -d: -f2) signal=$(echo "$line" | cut -d: -f3) @@ -244,8 +283,10 @@ Singleton { continue fi + # Check if SSID matches any profile name (simple check) + # This covers most cases where profile name equals or contains the SSID existing=false - if echo "$profiles" | grep -q "^$ssid$"; then + if echo "$profiles" | grep -qF "$ssid|"; then existing=true fi @@ -288,12 +329,24 @@ Singleton { } } - if (JSON.stringify(root.networks) !== JSON.stringify(nets)) { - Logger.log("Network", "Discovered", Object.keys(nets).length, "Wi-Fi networks") + // For logging purpose only + const oldSSIDs = Object.keys(root.networks) + const newSSIDs = Object.keys(nets) + const newNetworks = newSSIDs.filter(ssid => !oldSSIDs.includes(ssid)) + const lostNetworks = oldSSIDs.filter(ssid => !newSSIDs.includes(ssid)) + if (newNetworks.length > 0 || lostNetworks.length > 0) { + if (newNetworks.length > 0) { + Logger.log("Network", "New Wi-Fi SSID discovered:", newNetworks.join(", ")) + } + if (lostNetworks.length > 0) { + Logger.log("Network", "Wi-Fi SSID disappeared:", lostNetworks.join(", ")) + } + Logger.log("Network", "Total Wi-Fi SSIDs:", Object.keys(nets).length) } + + // Assign the results root.networks = nets root.scanning = false - } } @@ -343,11 +396,14 @@ Singleton { cacheAdapter.lastConnected = connectProcess.ssid saveCache() + // Immediately update the UI before scanning + root.updateNetworkStatus(connectProcess.ssid, true) + root.connecting = false root.connectingTo = "" - Logger.log("Network", "Connected to " + connectProcess.ssid) + Logger.log("Network", `Connected to network: "${connectProcess.ssid}"`) - // Rescan to update status + // Still do a scan to get accurate signal and security info delayedScanTimer.interval = 1000 delayedScanTimer.restart() } @@ -383,8 +439,27 @@ Singleton { running: false command: ["nmcli", "connection", "down", "id", ssid] - onRunningChanged: { - if (!running) { + stdout: StdioCollector { + onStreamFinished: { + Logger.log("Network", `Disconnected from network: "${disconnectProcess.ssid}"`) + + // Immediately update UI on successful disconnect + root.updateNetworkStatus(disconnectProcess.ssid, false) + root.disconnectingFrom = "" + + // Do a scan to refresh the list + delayedScanTimer.interval = 1000 + delayedScanTimer.restart() + } + } + + stderr: StdioCollector { + onStreamFinished: { + root.disconnectingFrom = "" + if (text.trim()) { + Logger.warn("Network", "Disconnect error: " + text) + } + // Still trigger a scan even on error delayedScanTimer.interval = 1000 delayedScanTimer.restart() } @@ -395,11 +470,68 @@ Singleton { id: forgetProcess property string ssid: "" running: false - command: ["nmcli", "connection", "delete", "id", ssid] - onRunningChanged: { - if (!running) { - delayedScanTimer.interval = 1000 + // Try multiple common profile name patterns + command: ["sh", "-c", ` + ssid="$1" + deleted=false + + # Try exact SSID match first + if nmcli connection delete id "$ssid" 2>/dev/null; then + echo "Deleted profile: $ssid" + deleted=true + fi + + # Try "Auto " pattern + if nmcli connection delete id "Auto $ssid" 2>/dev/null; then + echo "Deleted profile: Auto $ssid" + deleted=true + fi + + # Try " 1", " 2", etc. patterns + for i in 1 2 3; do + if nmcli connection delete id "$ssid $i" 2>/dev/null; then + echo "Deleted profile: $ssid $i" + deleted=true + fi + done + + if [ "$deleted" = "false" ]; then + echo "No profiles found for SSID: $ssid" + fi + `, "--", ssid] + + stdout: StdioCollector { + onStreamFinished: { + Logger.log("Network", `Forget network: "${forgetProcess.ssid}"`) + Logger.log("Network", text.trim().replace(/[\r\n]/g, " ")) + + // Update both cached and existing status immediately + let nets = root.networks + if (nets[forgetProcess.ssid]) { + nets[forgetProcess.ssid].cached = false + nets[forgetProcess.ssid].existing = false + // Trigger property change + root.networks = ({}) + root.networks = nets + } + + root.forgettingNetwork = "" + + // Quick scan to verify the profile is gone + delayedScanTimer.interval = 500 + delayedScanTimer.restart() + } + } + + stderr: StdioCollector { + onStreamFinished: { + root.forgettingNetwork = "" + if (text.trim() && !text.includes("No profiles found")) { + Logger.warn("Network", "Forget error: " + text) + } + // Still Trigger a scan even on error + delayedScanTimer.interval = 500 delayedScanTimer.restart() } } From 7860c41959de3d6b30db598a5b9ff10bfef507fe Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 14:14:47 -0400 Subject: [PATCH 03/15] Network/Wi-Fi: Removed auto polling every 30sec. Factorized more code and cleaned logs --- Modules/Bar/Widgets/WiFi.qml | 10 +--------- Modules/SettingsPanel/Tabs/NetworkTab.qml | 16 ++++----------- Modules/WiFiPanel/WiFiPanel.qml | 4 ++-- Services/NetworkService.qml | 24 +++++++++++++---------- 4 files changed, 21 insertions(+), 33 deletions(-) diff --git a/Modules/Bar/Widgets/WiFi.qml b/Modules/Bar/Widgets/WiFi.qml index cf5ddba..77f8664 100644 --- a/Modules/Bar/Widgets/WiFi.qml +++ b/Modules/Bar/Widgets/WiFi.qml @@ -15,14 +15,6 @@ NIconButton { sizeRatio: 0.8 - Component.onCompleted: { - Logger.log("WiFi", "Widget component completed") - Logger.log("WiFi", "NetworkService available:", !!NetworkService) - if (NetworkService) { - Logger.log("WiFi", "NetworkService.networks available:", !!NetworkService.networks) - } - } - colorBg: Color.mSurfaceVariant colorFg: Color.mOnSurface colorBorder: Color.transparent @@ -44,7 +36,7 @@ NIconButton { } return connected ? NetworkService.signalIcon(signalStrength) : "wifi_find" } catch (error) { - Logger.error("WiFi", "Error getting icon:", error) + Logger.error("Wi-Fi", "Error getting icon:", error) return "signal_wifi_bad" } } diff --git a/Modules/SettingsPanel/Tabs/NetworkTab.qml b/Modules/SettingsPanel/Tabs/NetworkTab.qml index 4b56ea7..0e1fd0d 100644 --- a/Modules/SettingsPanel/Tabs/NetworkTab.qml +++ b/Modules/SettingsPanel/Tabs/NetworkTab.qml @@ -12,22 +12,14 @@ ColumnLayout { spacing: Style.marginL * scaling NToggle { - label: "WiFi Enabled" - description: "Enable WiFi connectivity." + label: "Enable Wi-Fi" + description: "Enable Wi-Fi connectivity." checked: Settings.data.network.wifiEnabled - onToggled: checked => { - Settings.data.network.wifiEnabled = checked - NetworkService.setWifiEnabled(checked) - if (checked) { - ToastService.showNotice("WiFi", "Enabled") - } else { - ToastService.showNotice("WiFi", "Disabled") - } - } + onToggled: checked => NetworkService.setWifiEnabled(checked) } NToggle { - label: "Bluetooth Enabled" + label: "Enable Bluetooth" description: "Enable Bluetooth connectivity." checked: Settings.data.network.bluetoothEnabled onToggled: checked => { diff --git a/Modules/WiFiPanel/WiFiPanel.qml b/Modules/WiFiPanel/WiFiPanel.qml index c6c2427..1fa5f18 100644 --- a/Modules/WiFiPanel/WiFiPanel.qml +++ b/Modules/WiFiPanel/WiFiPanel.qml @@ -173,7 +173,7 @@ NPanel { } NText { - text: "Scanning for networks..." + text: "Searching for nearby networks..." font.pointSize: Style.fontSizeNormal * scaling color: Color.mOnSurfaceVariant Layout.alignment: Qt.AlignHCenter @@ -584,7 +584,7 @@ NPanel { } NButton { - text: "Scan Again" + text: "Scan again" icon: "refresh" Layout.alignment: Qt.AlignHCenter onClicked: NetworkService.scan() diff --git a/Services/NetworkService.qml b/Services/NetworkService.qml index f46e12a..c4b5820 100644 --- a/Services/NetworkService.qml +++ b/Services/NetworkService.qml @@ -40,6 +40,17 @@ Singleton { } } + Connections { + target: Settings.data.network + function onWifiEnabledChanged() { + if (Settings.data.network.wifiEnabled) { + ToastService.showNotice("Wi-Fi", "Enabled") + } else { + ToastService.showNotice("Wi-Fi", "Disabled") + } + } + } + Component.onCompleted: { Logger.log("Network", "Service initialized") syncWifiState() @@ -57,16 +68,7 @@ Singleton { saveDebounce.restart() } - // Single refresh timer for periodic scans - Timer { - id: refreshTimer - interval: 30000 - running: true - repeat: true - onTriggered: refresh() - } - - // Delayed scan timer for WiFi enable + // Delayed scan timer Timer { id: delayedScanTimer interval: 7000 @@ -100,6 +102,7 @@ Singleton { scanning = true lastError = "" scanProcess.running = true + Logger.log("Network", "Wi-Fi scan in progress...") } function connect(ssid, password = "") { @@ -330,6 +333,7 @@ Singleton { } // For logging purpose only + Logger.log("Network", "Wi-Fi scan completed") const oldSSIDs = Object.keys(root.networks) const newSSIDs = Object.keys(nets) const newNetworks = newSSIDs.filter(ssid => !oldSSIDs.includes(ssid)) From 809f16c27edd15566091e583331cb34b4206a018 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 15:18:53 -0400 Subject: [PATCH 04/15] Dock: improvements, new animations, always float, better look. --- Commons/Style.qml | 1 + Modules/Dock/Dock.qml | 130 ++++++++++++++++++++++++------------------ 2 files changed, 77 insertions(+), 54 deletions(-) diff --git a/Commons/Style.qml b/Commons/Style.qml index 902a225..a3a7d0f 100644 --- a/Commons/Style.qml +++ b/Commons/Style.qml @@ -60,6 +60,7 @@ Singleton { property int animationFast: Math.round(150 * Settings.data.general.animationSpeed) property int animationNormal: Math.round(300 * Settings.data.general.animationSpeed) property int animationSlow: Math.round(450 * Settings.data.general.animationSpeed) + property int animationSlowest: Math.round(750 * Settings.data.general.animationSpeed) // Dimensions property int barHeight: 36 diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index 9a71bba..2e34ca0 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -34,24 +34,28 @@ Variants { WlrLayershell.namespace: "noctalia-dock" - property bool autoHide: Settings.data.dock.autoHide - property bool hidden: autoHide - property int hideDelay: 500 - property int showDelay: 100 - property int hideAnimationDuration: Style.animationFast - property int showAnimationDuration: Style.animationFast - property int peekHeight: 7 * scaling - property int fullHeight: dockContainer.height - property int iconSize: 36 + readonly property bool autoHide: Settings.data.dock.autoHide + readonly property int hideDelay: 500 + readonly property int showDelay: 100 + readonly property int hideAnimationDuration: Style.animationFast + readonly property int showAnimationDuration: Style.animationFast + readonly property int peekHeight: 7 * scaling + readonly property int fullHeight: dockContainer.height + readonly property int iconSize: 36 * scaling + readonly property int floatingMargin: 12 * scaling // Margin to make dock float - // Bar positioning properties - property bool barAtBottom: Settings.data.bar.position === "bottom" - property int barHeight: barAtBottom ? (Settings.data.bar.height || 30) * scaling : 0 - property int dockSpacing: 4 * scaling // Space between dock and bar + // Bar detection and positioning properties + readonly property bool hasBar: modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) + || (Settings.data.bar.monitors.length === 0)) : false + readonly property bool barAtBottom: hasBar && Settings.data.bar.position === "bottom" + readonly property bool barAtTop: hasBar && Settings.data.bar.position === "top" + readonly property int barHeight: (barAtBottom || barAtTop) ? (Settings.data.bar.height || 30) * scaling : 0 + readonly property int dockSpacing: 8 * scaling // Space between dock and bar/edge // Track hover state property bool dockHovered: false property bool anyAppHovered: false + property bool hidden: autoHide // Dock is positioned at the bottom anchors.bottom: true @@ -63,11 +67,11 @@ Variants { // Make the window transparent color: Color.transparent - // Set the window size - always include space for peek area when auto-hide is enabled - implicitWidth: dockContainer.width - implicitHeight: fullHeight + (barAtBottom ? barHeight + dockSpacing : 0) + // Set the window size - include extra height only if bar is at bottom + implicitWidth: dockContainer.width + (floatingMargin * 2) + implicitHeight: fullHeight + floatingMargin + (barAtBottom ? barHeight + dockSpacing : 0) - // Position the entire window above the bar when bar is at bottom + // Position the entire window above the bar only when bar is at bottom margins.bottom: barAtBottom ? barHeight : 0 // Watch for autoHide setting changes @@ -111,7 +115,7 @@ Variants { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right - height: peekHeight + dockSpacing + height: peekHeight + floatingMargin + (barAtBottom ? dockSpacing : 0) hoverEnabled: autoHide visible: autoHide @@ -130,24 +134,32 @@ Variants { Rectangle { id: dockContainer - width: dockLayout.implicitWidth + 48 * scaling - height: iconSize * 1.4 * scaling + width: dockLayout.implicitWidth + Style.marginL * scaling * 2 + height: Math.round(iconSize * 1.6) color: Qt.alpha(Color.mSurface, Settings.data.dock.backgroundOpacity) anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom - anchors.bottomMargin: dockSpacing - topLeftRadius: Style.radiusL * scaling - topRightRadius: Style.radiusL * scaling + anchors.bottomMargin: floatingMargin + (barAtBottom ? dockSpacing : 0) + radius: Style.radiusL * scaling + border.width: Math.max(1, Style.borderS * scaling) + border.color: Color.mOutline - // Animate the dock sliding up and down - transform: Translate { - y: hidden ? (fullHeight - peekHeight) : 0 + // Fade and zoom animation properties + opacity: hidden ? 0 : 1 + scale: hidden ? 0.85 : 1 - Behavior on y { - NumberAnimation { - duration: hidden ? hideAnimationDuration : showAnimationDuration - easing.type: Easing.InOutQuad - } + Behavior on opacity { + NumberAnimation { + duration: hidden ? hideAnimationDuration : showAnimationDuration + easing.type: Easing.InOutQuad + } + } + + Behavior on scale { + NumberAnimation { + duration: hidden ? hideAnimationDuration : showAnimationDuration + easing.type: hidden ? Easing.InQuad : Easing.OutBack + easing.overshoot: hidden ? 0 : 1.05 } } @@ -179,7 +191,7 @@ Variants { Item { id: dock width: dockLayout.implicitWidth - height: parent.height - (20 * scaling) + height: parent.height - (Style.marginM * 2 * scaling) anchors.centerIn: parent NTooltip { @@ -203,39 +215,40 @@ Variants { Repeater { model: ToplevelManager ? ToplevelManager.toplevels : null - delegate: Rectangle { + delegate: Item { id: appButton - Layout.preferredWidth: iconSize * scaling - Layout.preferredHeight: iconSize * scaling + Layout.preferredWidth: iconSize + Layout.preferredHeight: iconSize Layout.alignment: Qt.AlignCenter - color: Color.transparent - radius: Style.radiusM * scaling - property bool isActive: ToplevelManager.activeToplevel && ToplevelManager.activeToplevel === modelData property bool hovered: appMouseArea.containsMouse property string appId: modelData ? modelData.appId : "" property string appTitle: modelData ? modelData.title : "" - // The icon + // The icon with better quality settings Image { id: appIcon - width: iconSize * scaling - height: iconSize * scaling + width: iconSize + height: iconSize anchors.centerIn: parent source: dock.getAppIcon(modelData) visible: source.toString() !== "" + sourceSize.width: iconSize * 2 + sourceSize.height: iconSize * 2 smooth: true - mipmap: false - antialiasing: false + mipmap: true + antialiasing: true fillMode: Image.PreserveAspectFit + cache: true - scale: appButton.hovered ? 1.1 : 1.0 + scale: appButton.hovered ? 1.15 : 1.0 Behavior on scale { NumberAnimation { - duration: Style.animationFast + duration: Style.animationNormal easing.type: Easing.OutBack + easing.overshoot: 1.2 } } } @@ -246,15 +259,15 @@ Variants { visible: !appIcon.visible text: "question_mark" font.family: "Material Symbols Rounded" - font.pointSize: iconSize * 0.7 * scaling + font.pointSize: iconSize * 0.7 color: appButton.isActive ? Color.mPrimary : Color.mOnSurfaceVariant - - scale: appButton.hovered ? 1.1 : 1.0 + scale: appButton.hovered ? 1.15 : 1.0 Behavior on scale { NumberAnimation { duration: Style.animationFast easing.type: Easing.OutBack + easing.overshoot: 1.2 } } } @@ -269,8 +282,8 @@ Variants { onEntered: { anyAppHovered = true const appName = appButton.appTitle || appButton.appId || "Unknown" - appTooltip.text = appName.length > 40 ? appName.substring(0, 37) + "..." : appName appTooltip.target = appButton + appTooltip.text = appName.length > 40 ? appName.substring(0, 37) + "..." : appName appTooltip.isVisible = true if (autoHide) { showTimer.stop() @@ -300,15 +313,24 @@ Variants { } } + // Active indicator Rectangle { visible: isActive - width: iconSize * 0.25 - height: 4 * scaling + width: iconSize * 0.2 + height: iconSize * 0.1 color: Color.mPrimary - radius: Style.radiusXS + radius: Style.radiusXS * scaling anchors.top: parent.bottom anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: Style.marginXXS * scaling + anchors.topMargin: Style.marginXXS * 1.5 * scaling + + // Pulse animation for active indicator + SequentialAnimation on opacity { + running: isActive + loops: Animation.Infinite + NumberAnimation { to: 0.6; duration: Style.animationSlowest; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: Style.animationSlowest; easing.type: Easing.InOutQuad } + } } } } @@ -317,4 +339,4 @@ Variants { } } } -} +} \ No newline at end of file From ac43b6d78aaec8385ad35ee84664a9d5ff2d5f8c Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 15:19:06 -0400 Subject: [PATCH 05/15] Dock: autoformatting --- Modules/Dock/Dock.qml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index 2e34ca0..0795dfa 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -45,8 +45,8 @@ Variants { readonly property int floatingMargin: 12 * scaling // Margin to make dock float // Bar detection and positioning properties - readonly property bool hasBar: modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) - || (Settings.data.bar.monitors.length === 0)) : false + readonly property bool hasBar: modelData.name ? (Settings.data.bar.monitors.includes(modelData.name) + || (Settings.data.bar.monitors.length === 0)) : false readonly property bool barAtBottom: hasBar && Settings.data.bar.position === "bottom" readonly property bool barAtTop: hasBar && Settings.data.bar.position === "top" readonly property int barHeight: (barAtBottom || barAtTop) ? (Settings.data.bar.height || 30) * scaling : 0 @@ -191,7 +191,7 @@ Variants { Item { id: dock width: dockLayout.implicitWidth - height: parent.height - (Style.marginM * 2 * scaling) + height: parent.height - (Style.marginM * 2 * scaling) anchors.centerIn: parent NTooltip { @@ -235,7 +235,7 @@ Variants { source: dock.getAppIcon(modelData) visible: source.toString() !== "" sourceSize.width: iconSize * 2 - sourceSize.height: iconSize * 2 + sourceSize.height: iconSize * 2 smooth: true mipmap: true antialiasing: true @@ -328,8 +328,16 @@ Variants { SequentialAnimation on opacity { running: isActive loops: Animation.Infinite - NumberAnimation { to: 0.6; duration: Style.animationSlowest; easing.type: Easing.InOutQuad } - NumberAnimation { to: 1.0; duration: Style.animationSlowest; easing.type: Easing.InOutQuad } + NumberAnimation { + to: 0.6 + duration: Style.animationSlowest + easing.type: Easing.InOutQuad + } + NumberAnimation { + to: 1.0 + duration: Style.animationSlowest + easing.type: Easing.InOutQuad + } } } } @@ -339,4 +347,4 @@ Variants { } } } -} \ No newline at end of file +} From 1bb1015fdf7aa7e0963e927d3b084f4a97293abf Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 15:25:57 -0400 Subject: [PATCH 06/15] Dock: one tooltip per app instead of a shared tooltip. avoid a few glitches when hovering. --- Modules/Dock/Dock.qml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Modules/Dock/Dock.qml b/Modules/Dock/Dock.qml index 0795dfa..04d64f7 100644 --- a/Modules/Dock/Dock.qml +++ b/Modules/Dock/Dock.qml @@ -194,12 +194,6 @@ Variants { height: parent.height - (Style.marginM * 2 * scaling) anchors.centerIn: parent - NTooltip { - id: appTooltip - visible: false - positionAbove: true - } - function getAppIcon(toplevel: Toplevel): string { if (!toplevel) return "" @@ -226,6 +220,14 @@ Variants { property string appId: modelData ? modelData.appId : "" property string appTitle: modelData ? modelData.title : "" + // Individual tooltip for this app + NTooltip { + id: appTooltip + target: appButton + positionAbove: true + visible: false + } + // The icon with better quality settings Image { id: appIcon @@ -282,7 +284,6 @@ Variants { onEntered: { anyAppHovered = true const appName = appButton.appTitle || appButton.appId || "Unknown" - appTooltip.target = appButton appTooltip.text = appName.length > 40 ? appName.substring(0, 37) + "..." : appName appTooltip.isVisible = true if (autoHide) { From 86c6135def476ec87210654641c0b89b5848515c Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 16:11:16 -0400 Subject: [PATCH 07/15] Network/Wi-Fi: improvements - Always check for ethernet status every 30s. Should not affect battery life. - Less aggressive scan intervals to give more times for slow adapters. --- Modules/Bar/Widgets/WiFi.qml | 2 +- Services/NetworkService.qml | 30 ++++++++++++++++-------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Modules/Bar/Widgets/WiFi.qml b/Modules/Bar/Widgets/WiFi.qml index 77f8664..fe8ff75 100644 --- a/Modules/Bar/Widgets/WiFi.qml +++ b/Modules/Bar/Widgets/WiFi.qml @@ -40,6 +40,6 @@ NIconButton { return "signal_wifi_bad" } } - tooltipText: "Network / Wi-Fi." + tooltipText: "Manage Wi-Fi." onClicked: PanelService.getPanel("wifiPanel")?.toggle(screen, this) } diff --git a/Services/NetworkService.qml b/Services/NetworkService.qml index c4b5820..ee22010 100644 --- a/Services/NetworkService.qml +++ b/Services/NetworkService.qml @@ -54,7 +54,7 @@ Singleton { Component.onCompleted: { Logger.log("Network", "Service initialized") syncWifiState() - refresh() + scan() } // Save cache with debounce @@ -75,6 +75,16 @@ Singleton { onTriggered: scan() } + // Ethernet check timer + // Always running every 30s + Timer { + id: ethernetCheckTimer + interval: 30000 + running: true + repeat: true + onTriggered: ethernetStateProcess.running = true + } + // Core functions function syncWifiState() { wifiStateProcess.running = true @@ -87,14 +97,6 @@ Singleton { wifiToggleProcess.running = true } - function refresh() { - ethernetStateProcess.running = true - - if (Settings.data.network.wifiEnabled) { - scan() - } - } - function scan() { if (scanning) return @@ -206,7 +208,7 @@ Singleton { // Processes Process { id: ethernetStateProcess - running: false + running: true command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"] stdout: StdioCollector { @@ -408,7 +410,7 @@ Singleton { Logger.log("Network", `Connected to network: "${connectProcess.ssid}"`) // Still do a scan to get accurate signal and security info - delayedScanTimer.interval = 1000 + delayedScanTimer.interval = 5000 delayedScanTimer.restart() } } @@ -522,8 +524,8 @@ Singleton { root.forgettingNetwork = "" - // Quick scan to verify the profile is gone - delayedScanTimer.interval = 500 + // Scan to verify the profile is gone + delayedScanTimer.interval = 5000 delayedScanTimer.restart() } } @@ -535,7 +537,7 @@ Singleton { Logger.warn("Network", "Forget error: " + text) } // Still Trigger a scan even on error - delayedScanTimer.interval = 500 + delayedScanTimer.interval = 1000 delayedScanTimer.restart() } } From 56993d3c00152ab387e399d80d42a9cb7177114e Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 18:16:59 -0400 Subject: [PATCH 08/15] Battery: Minimal BatteryService which only serve an appropriate icon. Trying different icons rotated 90 degrees to the left. --- Modules/Bar/Widgets/Battery.qml | 49 +++++++------------------ Modules/LockScreen/LockScreen.qml | 61 ++++++++++--------------------- Services/BatteryService.qml | 49 +++++++++++++++++++++++++ Widgets/NPill.qml | 3 ++ 4 files changed, 85 insertions(+), 77 deletions(-) create mode 100644 Services/BatteryService.qml diff --git a/Modules/Bar/Widgets/Battery.qml b/Modules/Bar/Widgets/Battery.qml index 22d8602..b4654fe 100644 --- a/Modules/Bar/Widgets/Battery.qml +++ b/Modules/Bar/Widgets/Battery.qml @@ -65,40 +65,17 @@ Item { // Test mode property bool testMode: false - property int testPercent: 20 - property bool testCharging: false + property int testPercent: 50 + property bool testCharging: true property var battery: UPower.displayDevice property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent) property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0) property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false) - // Choose icon based on charge and charging state - function batteryIcon() { - if (!isReady || !battery.isLaptopBattery) - return "battery_android_alert" - if (charging) - return "battery_android_bolt" - if (percent >= 95) - return "battery_android_full" - // Hardcoded battery symbols - if (percent >= 85) - return "battery_android_6" - if (percent >= 70) - return "battery_android_5" - if (percent >= 55) - return "battery_android_4" - if (percent >= 40) - return "battery_android_3" - if (percent >= 25) - return "battery_android_2" - if (percent >= 10) - return "battery_android_1" - if (percent >= 0) - return "battery_android_0" - } - rightOpen: BarWidgetRegistry.getNPillDirection(root) - icon: batteryIcon() + icon: testMode ? BatteryService.getIcon(testPercent, testCharging, true) : BatteryService.getIcon(percent, + charging, isReady) + iconRotation: -90 text: ((isReady && battery.isLaptopBattery) || testMode) ? Math.round(percent) + "%" : "-" textColor: charging ? Color.mPrimary : Color.mOnSurface iconCircleColor: Color.mPrimary @@ -109,30 +86,30 @@ Item { tooltipText: { let lines = [] if (testMode) { - lines.push("Time left: " + Time.formatVagueHumanReadableDuration(12345)) + lines.push(`Time left: ${Time.formatVagueHumanReadableDuration(12345)}.`) return lines.join("\n") } if (!isReady || !battery.isLaptopBattery) { - return "No battery detected" + return "No battery detected." } if (battery.timeToEmpty > 0) { - lines.push("Time left: " + Time.formatVagueHumanReadableDuration(battery.timeToEmpty)) + lines.push(`Time left: ${Time.formatVagueHumanReadableDuration(battery.timeToEmpty)}.`) } if (battery.timeToFull > 0) { - lines.push("Time until full: " + Time.formatVagueHumanReadableDuration(battery.timeToFull)) + lines.push(`Time until full: ${Time.formatVagueHumanReadableDuration(battery.timeToFull)}.`) } if (battery.changeRate !== undefined) { const rate = battery.changeRate if (rate > 0) { - lines.push(charging ? "Charging rate: " + rate.toFixed(2) + " W" : "Discharging rate: " + rate.toFixed( - 2) + " W") + lines.push(charging ? "Charging rate: " + rate.toFixed(2) + " W." : "Discharging rate: " + rate.toFixed( + 2) + " W.") } else if (rate < 0) { - lines.push("Discharging rate: " + Math.abs(rate).toFixed(2) + " W") + lines.push("Discharging rate: " + Math.abs(rate).toFixed(2) + " W.") } else { lines.push("Estimating...") } } else { - lines.push(charging ? "Charging" : "Discharging") + lines.push(charging ? "Charging." : "Discharging.") } if (battery.healthPercentage !== undefined && battery.healthPercentage > 0) { lines.push("Health: " + Math.round(battery.healthPercentage) + "%") diff --git a/Modules/LockScreen/LockScreen.qml b/Modules/LockScreen/LockScreen.qml index 3382b69..25b69b1 100644 --- a/Modules/LockScreen/LockScreen.qml +++ b/Modules/LockScreen/LockScreen.qml @@ -58,29 +58,6 @@ Loader { property real percent: isReady ? (battery.percentage * 100) : 0 property bool charging: isReady ? battery.state === UPowerDeviceState.Charging : false property bool batteryVisible: isReady && percent > 0 - - function getIcon() { - if (!batteryVisible) - return "" - if (charging) - return "battery_android_bolt" - if (percent >= 95) - return "battery_android_full" - if (percent >= 85) - return "battery_android_6" - if (percent >= 70) - return "battery_android_5" - if (percent >= 55) - return "battery_android_4" - if (percent >= 40) - return "battery_android_3" - if (percent >= 25) - return "battery_android_2" - if (percent >= 10) - return "battery_android_1" - if (percent >= 0) - return "battery_android_0" - } } Item { @@ -420,7 +397,7 @@ Loader { anchors.bottomMargin: Style.marginM * scaling anchors.leftMargin: Style.marginL * scaling anchors.rightMargin: Style.marginL * scaling - spacing: Style.marginM * scaling + spacing: Style.marginL * scaling NText { text: "SECURE TERMINAL" @@ -431,23 +408,6 @@ Loader { Layout.fillWidth: true } - RowLayout { - spacing: Style.marginS * scaling - visible: batteryIndicator.batteryVisible - NIcon { - text: batteryIndicator.getIcon() - font.pointSize: Style.fontSizeM * scaling - color: batteryIndicator.charging ? Color.mPrimary : Color.mOnSurface - } - NText { - text: Math.round(batteryIndicator.percent) + "%" - color: Color.mOnSurface - font.family: Settings.data.ui.fontFixed - font.pointSize: Style.fontSizeM * scaling - font.weight: Style.fontWeightBold - } - } - RowLayout { spacing: Style.marginS * scaling NText { @@ -463,6 +423,25 @@ Loader { color: Color.mOnSurface } } + + RowLayout { + spacing: Style.marginS * scaling + visible: batteryIndicator.batteryVisible + NIcon { + text: BatteryService.getIcon(batteryIndicator.percent, batteryIndicator.charging, + batteryIndicator.isReady) + font.pointSize: Style.fontSizeM * scaling + color: batteryIndicator.charging ? Color.mPrimary : Color.mOnSurface + rotation: -90 + } + NText { + text: Math.round(batteryIndicator.percent) + "%" + color: Color.mOnSurface + font.family: Settings.data.ui.fontFixed + font.pointSize: Style.fontSizeM * scaling + font.weight: Style.fontWeightBold + } + } } } diff --git a/Services/BatteryService.qml b/Services/BatteryService.qml new file mode 100644 index 0000000..6ceb872 --- /dev/null +++ b/Services/BatteryService.qml @@ -0,0 +1,49 @@ +pragma Singleton + +import Quickshell +import Quickshell.Services.UPower + +Singleton { + id: root + + // Choose icon based on charge and charging state + function getIcon(percent, charging, isReady) { + if (!isReady) { + return "battery_error" + } + + if (charging) { + if (percent >= 95) + return "battery_full" + if (percent >= 85) + return "battery_charging_90" + if (percent >= 65) + return "battery_charging_80" + if (percent >= 55) + return "battery_charging_60" + if (percent >= 45) + return "battery_charging_50" + if (percent >= 25) + return "battery_charging_30" + if (percent >= 0) + return "battery_charging_20" + } else { + if (percent >= 95) + return "battery_full" + if (percent >= 85) + return "battery_6_bar" + if (percent >= 70) + return "battery_5_bar" + if (percent >= 55) + return "battery_4_bar" + if (percent >= 40) + return "battery_3_bar" + if (percent >= 25) + return "battery_2_bar" + if (percent >= 10) + return "battery_1_bar" + if (percent >= 0) + return "battery_0_bar" + } + } +} diff --git a/Widgets/NPill.qml b/Widgets/NPill.qml index 2432544..94719b3 100644 --- a/Widgets/NPill.qml +++ b/Widgets/NPill.qml @@ -14,6 +14,8 @@ Item { property color iconCircleColor: Color.mPrimary property color iconTextColor: Color.mSurface property color collapsedIconColor: Color.mOnSurface + + property real iconRotation: 0 property real sizeRatio: 0.8 property bool autoHide: false property bool forceOpen: false @@ -110,6 +112,7 @@ Item { NIcon { text: root.icon + rotation: root.iconRotation font.pointSize: Style.fontSizeM * scaling // When forced shown, use pill text color; otherwise accent color when hovered color: forceOpen ? textColor : (showPill ? iconTextColor : Color.mOnSurface) From 9bc6479c9222e44faa146d7e972ca46d3e5235ad Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 18:34:44 -0400 Subject: [PATCH 09/15] NPill: for battery use a very light outline around the icon --- Widgets/NPill.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Widgets/NPill.qml b/Widgets/NPill.qml index 94719b3..794caa9 100644 --- a/Widgets/NPill.qml +++ b/Widgets/NPill.qml @@ -99,6 +99,8 @@ Item { // When forced shown, match pill background; otherwise use accent when hovered color: forceOpen ? pillColor : (showPill ? iconCircleColor : Color.mSurfaceVariant) anchors.verticalCenter: parent.verticalCenter + border.width: Math.max(1, Style.borderS * scaling) + border.color: forceOpen ? Qt.alpha(Color.mOutline, 0.5) : Color.transparent anchors.left: rightOpen ? parent.left : undefined anchors.right: rightOpen ? undefined : parent.right From 36d3a50f217db5be164b7c8e1424c32e27ab8a3a Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 19:27:32 -0400 Subject: [PATCH 10/15] Brightness: brings back realtime brightness monitoring for internal(laptop) display. The pill will open and show the change in real time --- Modules/Bar/Widgets/Brightness.qml | 26 +++++------ Services/BrightnessService.qml | 72 ++++++++++++++++++++++++------ 2 files changed, 70 insertions(+), 28 deletions(-) diff --git a/Modules/Bar/Widgets/Brightness.qml b/Modules/Bar/Widgets/Brightness.qml index 4b6d91a..c16866c 100644 --- a/Modules/Bar/Widgets/Brightness.qml +++ b/Modules/Bar/Widgets/Brightness.qml @@ -37,28 +37,26 @@ Item { target: getMonitor() ignoreUnknownSignals: true function onBrightnessUpdated() { - Logger.log("Bar-Brightness", "OnBrightnessUpdated") - var monitor = getMonitor() - if (!monitor) - return - var currentBrightness = monitor.brightness - - // Ignore if this is the first time or if brightness hasn't actually changed + // Ignore if this is the first time we receive an update. + // Most likely service just kicked off. if (!firstBrightnessReceived) { firstBrightnessReceived = true - monitor.lastBrightness = currentBrightness return } - // Only show pill if brightness actually changed (not just loaded from settings) - if (Math.abs(currentBrightness - monitor.lastBrightness) > 0.1) { - pill.show() - } - - monitor.lastBrightness = currentBrightness + pill.show() + hideTimerAfterChange.restart() } } + Timer { + id: hideTimerAfterChange + interval: 2500 + running: false + repeat: false + onTriggered: pill.hide() + } + NPill { id: pill diff --git a/Services/BrightnessService.qml b/Services/BrightnessService.qml index 6cf1b5d..d14b166 100644 --- a/Services/BrightnessService.qml +++ b/Services/BrightnessService.qml @@ -110,9 +110,43 @@ Singleton { property real lastBrightness: 0 property real queuedBrightness: NaN + // For internal displays - store the backlight device path + property string backlightDevice: "" + property string brightnessPath: "" + property string maxBrightnessPath: "" + property int maxBrightness: 100 + property bool ignoreNextChange: false + // Signal for brightness changes signal brightnessUpdated(real newBrightness) + // FileView to watch for external brightness changes (internal displays only) + readonly property FileView brightnessWatcher: FileView { + id: brightnessWatcher + // Only set path for internal displays with a valid brightness path + path: (!monitor.isDdc && !monitor.isAppleDisplay && monitor.brightnessPath !== "") ? monitor.brightnessPath : "" + watchChanges: path !== "" + onFileChanged: { + reload() + if (monitor.ignoreNextChange) { + monitor.ignoreNextChange = false + return + } + if (text() === "") + return + var current = parseInt(text().trim()) + if (!isNaN(current) && monitor.maxBrightness > 0) { + var newBrightness = current / monitor.maxBrightness + // Only update if it's actually different (avoid feedback loops) + if (Math.abs(newBrightness - monitor.brightness) > 0.01) { + monitor.brightness = newBrightness + monitor.brightnessUpdated(monitor.brightness) + //Logger.log("Brightness", "External change detected:", monitor.modelData.name, monitor.brightness) + } + } + } + } + // Initialize brightness readonly property Process initProc: Process { stdout: StdioCollector { @@ -121,8 +155,8 @@ Singleton { if (dataText === "") { return } - Logger.log("Brightness", "Raw brightness data for", monitor.modelData.name + ":", dataText) + //Logger.log("Brightness", "Raw brightness data for", monitor.modelData.name + ":", dataText) if (monitor.isAppleDisplay) { var val = parseInt(dataText) if (!isNaN(val)) { @@ -140,14 +174,20 @@ Singleton { } } } else { - // Internal backlight - var parts = dataText.split(" ") - if (parts.length >= 2) { - var current = parseInt(parts[0]) - var max = parseInt(parts[1]) + // Internal backlight - parse the response which includes device path + var lines = dataText.split("\n") + if (lines.length >= 3) { + 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]) 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) } } } @@ -171,7 +211,7 @@ Singleton { function increaseBrightness(): void { var stepSize = Settings.data.brightness.brightnessStep / 100.0 - setBrightnessDebounced(brightness + stepSize) + setBrightnessDebounced(monitor.brightness + stepSize) } function decreaseBrightness(): void { @@ -183,22 +223,23 @@ Singleton { value = Math.max(0, Math.min(1, value)) var rounded = Math.round(value * 100) - if (Math.round(brightness * 100) === rounded) + if (Math.round(monitor.brightness * 100) === rounded) return if (isDdc && timer.running) { - queuedBrightness = value + monitor.queuedBrightness = value return } - brightness = value - brightnessUpdated(brightness) + monitor.brightness = value + brightnessUpdated(monitor.brightness) if (isAppleDisplay) { Quickshell.execDetached(["asdbctl", "set", rounded]) } else if (isDdc) { Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded]) } else { + monitor.ignoreNextChange = true Quickshell.execDetached(["brightnessctl", "s", rounded + "%"]) } @@ -208,7 +249,7 @@ Singleton { } function setBrightnessDebounced(value: real): void { - queuedBrightness = value + monitor.queuedBrightness = value timer.restart() } @@ -218,8 +259,11 @@ Singleton { } else if (isDdc) { initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] } else { - // Internal backlight - try to find the first available backlight device - initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then echo \"$(cat $dev/brightness) $(cat $dev/max_brightness)\"; break; fi; done"] + // 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"] } initProc.running = true } From 2bc1d53b18bdd3068ed5d13c2be14465bc72d952 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 20:50:49 -0400 Subject: [PATCH 11/15] SysStat Service: Porting code to JS/QML instead of an external bash --- Modules/Bar/Widgets/SystemMonitor.qml | 2 +- Modules/SidePanel/Cards/SystemMonitorCard.qml | 4 +- Services/SystemStatService.qml | 367 ++++++++++++++++-- 3 files changed, 346 insertions(+), 27 deletions(-) diff --git a/Modules/Bar/Widgets/SystemMonitor.qml b/Modules/Bar/Widgets/SystemMonitor.qml index 6c2346c..e57d599 100644 --- a/Modules/Bar/Widgets/SystemMonitor.qml +++ b/Modules/Bar/Widgets/SystemMonitor.qml @@ -88,7 +88,7 @@ RowLayout { } NText { - text: `${SystemStatService.memoryUsageGb}G` + text: `${SystemStatService.memGb}G` font.family: Settings.data.ui.fontFixed font.pointSize: Style.fontSizeS * scaling font.weight: Style.fontWeightMedium diff --git a/Modules/SidePanel/Cards/SystemMonitorCard.qml b/Modules/SidePanel/Cards/SystemMonitorCard.qml index 2fc18de..9d3154d 100644 --- a/Modules/SidePanel/Cards/SystemMonitorCard.qml +++ b/Modules/SidePanel/Cards/SystemMonitorCard.qml @@ -40,7 +40,7 @@ NBox { height: 68 * scaling } NCircleStat { - value: SystemStatService.memoryUsagePer + value: SystemStatService.memPercent icon: "memory" flat: true contentScale: 0.8 @@ -48,7 +48,7 @@ NBox { height: 68 * scaling } NCircleStat { - value: SystemStatService.diskUsage + value: SystemStatService.diskPercent icon: "hard_drive" flat: true contentScale: 0.8 diff --git a/Services/SystemStatService.qml b/Services/SystemStatService.qml index 4f09c1d..5de99f0 100644 --- a/Services/SystemStatService.qml +++ b/Services/SystemStatService.qml @@ -4,6 +4,7 @@ import QtQuick import Qt.labs.folderlistmodel import Quickshell import Quickshell.Io +import qs.Commons Singleton { id: root @@ -11,12 +12,313 @@ Singleton { // Public values property real cpuUsage: 0 property real cpuTemp: 0 - property real memoryUsageGb: 0 - property real memoryUsagePer: 0 - property real diskUsage: 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 + + // 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: undefined + property real prevTxBytes: undefined + 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 + property var intelTempValues: [] + property int intelTempFilesChecked: 0 + property int intelTempMaxFiles: 20 // Will test up to temp20_input + + // -------------------------------------------- + Component.onCompleted: { + Logger.log("SystemStat", "Service started with interval:", root.sleepDuration, "ms") + + // Kickoff the cpu name detection for temperature + cpuTempNameReader.checkNext() + } + + // -------------------------------------------- + // 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() + netDevFile.reload() + + // Run df (disk free) one time + dfProcess.running = true + + updateCpuTemperature() + } + } + + // -------------------------------------------- + // 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 + } + } + } + } + + // -------------------------------------------- + // CPU Temperature + // It's more complex. + // ---- + // #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower" + FileView { + id: cpuTempNameReader + property int currentIndex: 0 + + function checkNext() { + if (currentIndex >= 10) { + // Check up to hwmon10 + Logger.warn("No supported temperature sensor found") + return + } + + //Logger.log("SystemStat", "---- Probing: hwmon", currentIndex) + cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name` + cpuTempNameReader.reload() + } + + onLoaded: { + const name = text().trim() + if (root.supportedTempCpuSensorNames.includes(name)) { + root.cpuTempSensorName = name + root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}` + Logger.log("SystemStat", `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() + }) + } + } + + // ---- + // #2 - Read sensor value + FileView { + id: cpuTempReader + printErrors: false + + onLoaded: { + const data = text().trim() + if (root.cpuTempSensorName === "coretemp") { + // For Intel, collect all temperature values + const temp = parseInt(data) / 1000.0 + //console.log(temp, cpuTempReader.path) + 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) + } + } + onLoadFailed: function (error) { + Qt.callLater(() => { + // Qt.callLater is mandatory + checkNextIntelTemp() + }) + } + } + + // ------------------------------------------------------- + // Parse memory info from /proc/meminfo + function parseMemoryInfo(text) { + if (!text) + return + + 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 + } + } + + if (memTotal > 0) { + const usageKb = memTotal - memAvailable + root.memGb = (usageKb / 1000000).toFixed(1) + root.memPercent = Math.round((usageKb / memTotal) * 100) + } + } + + // ------------------------------------------------------- + // 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.prevCpuStats = stats + } + + // ------------------------------------------------------- + // Calculate RX and TX speed from /proc/net/dev + // Average speed of all interfaces excepted 'lo' + 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 (iface === 'lo') { + 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 && root.prevRxBytes !== undefined) { + 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 + } + + // ------------------------------------------------------- // Helper function to format network speeds function formatSpeed(bytesPerSecond) { if (bytesPerSecond < 1024) { @@ -30,27 +332,44 @@ Singleton { } } - // Background process emitting one JSON line per sample - Process { - id: reader - running: true - command: ["sh", "-c", Quickshell.shellDir + "/Bin/system-stats.sh"] - stdout: SplitParser { - onRead: function (line) { - try { - const data = JSON.parse(line) - root.cpuUsage = data.cpu - root.cpuTemp = data.cputemp - root.memoryUsageGb = data.memgb - root.memoryUsagePer = data.memper - root.diskUsage = data.diskper - root.rxSpeed = parseFloat(data.rx_speed) || 0 - root.txSpeed = parseFloat(data.tx_speed) || 0 - } catch (e) { - - // ignore malformed lines - } - } + // ------------------------------------------------------- + // 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() } } + + // ------------------------------------------------------- + // 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) + Logger.log("SystemStat", `Averaged ${root.intelTempValues.length} CPU thermal sensors: ${root.cpuTemp}°C`) + } else { + Logger.warn("SystemStat", "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() + } } From fb2d42da57adf38ee469f334159b3f2a6f3dd62c Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 6 Sep 2025 23:47:17 -0400 Subject: [PATCH 12/15] SysStat Service: less log on intel CPU --- Services/SystemStatService.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Services/SystemStatService.qml b/Services/SystemStatService.qml index 5de99f0..71aa760 100644 --- a/Services/SystemStatService.qml +++ b/Services/SystemStatService.qml @@ -35,7 +35,7 @@ Singleton { readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"] property string cpuTempSensorName: "" property string cpuTempHwmonPath: "" - // For Intel coretemp averaging + // 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 @@ -359,7 +359,7 @@ Singleton { sum += root.intelTempValues[i] } root.cpuTemp = Math.round(sum / root.intelTempValues.length) - Logger.log("SystemStat", `Averaged ${root.intelTempValues.length} CPU thermal sensors: ${root.cpuTemp}°C`) + //Logger.log("SystemStat", `Averaged ${root.intelTempValues.length} CPU thermal sensors: ${root.cpuTemp}°C`) } else { Logger.warn("SystemStat", "No temperature sensors found for coretemp") root.cpuTemp = 0 From f27608947c31c9bd64af429ecc5a358d8cde7837 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sun, 7 Sep 2025 00:06:58 -0400 Subject: [PATCH 13/15] Settings: slightly more compact tabs --- Modules/SettingsPanel/SettingsPanel.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Modules/SettingsPanel/SettingsPanel.qml b/Modules/SettingsPanel/SettingsPanel.qml index 1e6d6cc..8bfbe77 100644 --- a/Modules/SettingsPanel/SettingsPanel.qml +++ b/Modules/SettingsPanel/SettingsPanel.qml @@ -368,7 +368,7 @@ NPanel { ColumnLayout { anchors.fill: parent anchors.margins: Style.marginS * scaling - spacing: Style.marginXS * 1.5 * scaling + spacing: Style.marginXS * scaling Repeater { id: sections @@ -398,7 +398,8 @@ NPanel { RowLayout { id: tabEntryRow anchors.fill: parent - anchors.margins: Style.marginS * scaling + anchors.leftMargin: Style.marginS * scaling + anchors.rightMargin: Style.marginS * scaling spacing: Style.marginS * scaling // Tab icon From 9010a1668b50a7820f9175be5c42d0fb534c0067 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sun, 7 Sep 2025 00:43:57 -0400 Subject: [PATCH 14/15] SysStat: fixed warning. cant assign undefined to real --- Services/SystemStatService.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Services/SystemStatService.qml b/Services/SystemStatService.qml index 71aa760..7328f71 100644 --- a/Services/SystemStatService.qml +++ b/Services/SystemStatService.qml @@ -27,8 +27,8 @@ Singleton { // 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: undefined - property real prevTxBytes: undefined + property real prevRxBytes: 0 + property real prevTxBytes: 0 property real prevTime: 0 // Cpu temperature is the most complex @@ -292,7 +292,7 @@ Singleton { } // Compute only if we have a previous run to compare to. - if (root.prevTime > 0 && root.prevRxBytes !== undefined) { + if (root.prevTime > 0) { const timeDiff = currentTime - root.prevTime // Avoid division by zero if time hasn't passed. From adac96ee84ddc799e31a0841384bf53582fc13e4 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sun, 7 Sep 2025 00:49:59 -0400 Subject: [PATCH 15/15] SidePanel: proper height computation --- Modules/SidePanel/SidePanel.qml | 61 ++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/Modules/SidePanel/SidePanel.qml b/Modules/SidePanel/SidePanel.qml index 6225494..4dba15a 100644 --- a/Modules/SidePanel/SidePanel.qml +++ b/Modules/SidePanel/SidePanel.qml @@ -11,71 +11,92 @@ NPanel { id: root panelWidth: 460 * scaling - panelHeight: 708 * scaling + panelHeight: contentHeight + + // Default height, will be modified via binding when the content is fully loaded + property real contentHeight: 720 * scaling panelContent: Item { id: content property real cardSpacing: Style.marginL * scaling - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: content.cardSpacing - implicitHeight: layout.implicitHeight + width: root.panelWidth + implicitHeight: layout.implicitHeight + (2 * cardSpacing) + height: implicitHeight - // Layout content (not vertically anchored so implicitHeight is valid) + // Update parent's contentHeight whenever our height changes + onHeightChanged: { + root.contentHeight = height + } + + onImplicitHeightChanged: { + if (implicitHeight > 0) { + root.contentHeight = implicitHeight + } + } + + // Layout content ColumnLayout { id: layout - // Use the same spacing value horizontally and vertically - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top + x: content.cardSpacing + y: content.cardSpacing + width: parent.width - (2 * content.cardSpacing) spacing: content.cardSpacing // Cards (consistent inter-card spacing via ColumnLayout spacing) - ProfileCard {// Layout.topMargin: 0 - // Layout.bottomMargin: 0 + ProfileCard { + id: profileCard + Layout.fillWidth: true } - WeatherCard {// Layout.topMargin: 0 - // Layout.bottomMargin: 0 + + WeatherCard { + id: weatherCard + Layout.fillWidth: true } // Middle section: media + stats column RowLayout { + id: middleRow Layout.fillWidth: true - Layout.topMargin: 0 - Layout.bottomMargin: 0 + Layout.minimumHeight: 280 * scaling + Layout.preferredHeight: Math.max(280 * scaling, statsCard.implicitHeight) spacing: content.cardSpacing // Media card MediaCard { id: mediaCard Layout.fillWidth: true - implicitHeight: statsCard.implicitHeight + Layout.fillHeight: true } // System monitors combined in one card SystemMonitorCard { id: statsCard + Layout.alignment: Qt.AlignTop } } // Bottom actions (two grouped rows of round buttons) RowLayout { + id: bottomRow Layout.fillWidth: true - Layout.topMargin: 0 - Layout.bottomMargin: 0 + Layout.minimumHeight: 60 * scaling + Layout.preferredHeight: Math.max(60 * scaling, powerProfilesCard.implicitHeight, utilitiesCard.implicitHeight) spacing: content.cardSpacing // Power Profiles switcher PowerProfilesCard { + id: powerProfilesCard spacing: content.cardSpacing + Layout.fillWidth: true } // Utilities buttons UtilitiesCard { + id: utilitiesCard spacing: content.cardSpacing + Layout.fillWidth: true } } }