From b28eec593060815d3be07b16b209c818ce008eeb Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Sat, 28 Feb 2026 13:57:29 +0100 Subject: [PATCH] resource popout --- Config/Config.qml | 9 + Config/DashboardConfig.qml | 11 + Drawers/Backgrounds.qml | 7 + Drawers/Bar.qml | 9 +- Drawers/Panels.qml | 13 +- Helpers/NetworkUsage.qml | 234 +++++ Helpers/SystemUsage.qml | 153 ++- Modules/Bar/BarLoader.qml | 27 +- Modules/Content.qml | 8 - Modules/Launcher/Items/WallpaperItem.qml | 2 +- Modules/Launcher/WallpaperList.qml | 4 +- ...ourcePopout.qml => ResourcePopout-old.qml} | 0 Modules/Resources.qml | 6 + Modules/Resources/Background.qml | 66 ++ Modules/Resources/Content.qml | 975 ++++++++++++++++++ Modules/Resources/Wrapper.qml | 86 ++ Modules/Wallpaper/Wallpaper.qml | 2 +- 17 files changed, 1547 insertions(+), 65 deletions(-) create mode 100644 Helpers/NetworkUsage.qml rename Modules/{ResourcePopout.qml => ResourcePopout-old.qml} (100%) create mode 100644 Modules/Resources/Background.qml create mode 100644 Modules/Resources/Content.qml create mode 100644 Modules/Resources/Wrapper.qml diff --git a/Config/Config.qml b/Config/Config.qml index d5cf35e..a995ca9 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -121,7 +121,16 @@ Singleton { return { enabled: dashboard.enabled, mediaUpdateInterval: dashboard.mediaUpdateInterval, + resourceUpdateInterval: dashboard.resourceUpdateInterval, dragThreshold: dashboard.dragThreshold, + performance: { + showBattery: dashboard.performance.showBattery, + showGpu: dashboard.performance.showGpu, + showCpu: dashboard.performance.showCpu, + showMemory: dashboard.performance.showMemory, + showStorage: dashboard.performance.showStorage, + showNetwork: dashboard.performance.showNetwork + }, sizes: { tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight, tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing, diff --git a/Config/DashboardConfig.qml b/Config/DashboardConfig.qml index 6b55776..0b6a610 100644 --- a/Config/DashboardConfig.qml +++ b/Config/DashboardConfig.qml @@ -4,9 +4,20 @@ JsonObject { property int dragThreshold: 50 property bool enabled: true property int mediaUpdateInterval: 500 + property Performance performance: Performance { + } + property int resourceUpdateInterval: 1000 property Sizes sizes: Sizes { } + component Performance: JsonObject { + property bool showBattery: true + property bool showCpu: true + property bool showGpu: true + property bool showMemory: true + property bool showNetwork: true + property bool showStorage: true + } component Sizes: JsonObject { readonly property int dateTimeWidth: 110 readonly property int infoIconSize: 25 diff --git a/Drawers/Backgrounds.qml b/Drawers/Backgrounds.qml index 352b753..c97e2ee 100644 --- a/Drawers/Backgrounds.qml +++ b/Drawers/Backgrounds.qml @@ -10,6 +10,7 @@ import qs.Modules.Notifications.Sidebar.Utils as Utils import qs.Modules.Dashboard as Dashboard import qs.Modules.Osd as Osd import qs.Modules.Launcher as Launcher +import qs.Modules.Resources as Resources // import qs.Modules.Settings as Settings @@ -30,6 +31,12 @@ Shape { } } + Resources.Background { + startX: 0 - rounding + startY: 0 + wrapper: root.panels.resources + } + Osd.Background { startX: root.width - root.panels.sidebar.width startY: (root.height - wrapper.height) / 2 - rounding diff --git a/Drawers/Bar.qml b/Drawers/Bar.qml index c5bbdde..b7f546f 100644 --- a/Drawers/Bar.qml +++ b/Drawers/Bar.qml @@ -28,7 +28,7 @@ Variants { property bool trayMenuVisible: false WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || visibilities.resources ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.namespace: "ZShell-Bar" color: "transparent" contentItem.focus: true @@ -52,6 +52,7 @@ Variants { visibilities.dashboard = false; visibilities.osd = false; visibilities.settings = false; + visibilities.resources; } PanelWindow { @@ -97,7 +98,7 @@ Variants { HyprlandFocusGrab { id: focusGrab - active: visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu")) + active: visibilities.resources || visibilities.launcher || visibilities.sidebar || visibilities.dashboard || visibilities.settings || (panels.popouts.hasCurrent && panels.popouts.currentName.startsWith("traymenu")) windows: [bar] onCleared: { @@ -106,6 +107,7 @@ Variants { visibilities.dashboard = false; visibilities.osd = false; visibilities.settings = false; + visibilities.resources = false; panels.popouts.hasCurrent = false; } } @@ -118,6 +120,7 @@ Variants { property bool launcher property bool notif: NotifServer.popups.length > 0 property bool osd + property bool resources property bool settings property bool sidebar @@ -127,7 +130,7 @@ Variants { Binding { property: "bar" target: visibilities - value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || visibilities.notif + value: visibilities.sidebar || visibilities.dashboard || visibilities.osd || visibilities.notif || visibilities.resources when: Config.barConfig.autoHide } diff --git a/Drawers/Panels.qml b/Drawers/Panels.qml index df0fba5..508b5ee 100644 --- a/Drawers/Panels.qml +++ b/Drawers/Panels.qml @@ -9,6 +9,7 @@ import qs.Modules.Dashboard as Dashboard import qs.Modules.Osd as Osd import qs.Components.Toast as Toasts import qs.Modules.Launcher as Launcher +import qs.Modules.Resources as Resources // import qs.Modules.Settings as Settings import qs.Config @@ -21,6 +22,7 @@ Item { readonly property alias notifications: notifications readonly property alias osd: osd readonly property alias popouts: popouts + readonly property alias resources: resources required property ShellScreen screen // readonly property alias settings: settings readonly property alias sidebar: sidebar @@ -30,14 +32,21 @@ Item { anchors.fill: parent // anchors.margins: 8 - anchors.topMargin: Config.barConfig.autoHide && !visibilities.bar ? 0 : - bar.implicitHeight + anchors.topMargin: Config.barConfig.autoHide && !visibilities.bar ? 0 : bar.implicitHeight Behavior on anchors.topMargin { Anim { } } + Resources.Wrapper { + id: resources + + anchors.left: parent.left + anchors.top: parent.top + visibilities: root.visibilities + } + Osd.Wrapper { id: osd diff --git a/Helpers/NetworkUsage.qml b/Helpers/NetworkUsage.qml new file mode 100644 index 0000000..a8178ad --- /dev/null +++ b/Helpers/NetworkUsage.qml @@ -0,0 +1,234 @@ +pragma Singleton + +import qs.Config + +import Quickshell +import Quickshell.Io + +import QtQuick + +Singleton { + id: root + + property var _downloadHistory: [] + + // Private properties + property real _downloadSpeed: 0 + property real _downloadTotal: 0 + + // Initial readings for calculating totals + property real _initialRxBytes: 0 + property real _initialTxBytes: 0 + property bool _initialized: false + + // Previous readings for calculating speed + property real _prevRxBytes: 0 + property real _prevTimestamp: 0 + property real _prevTxBytes: 0 + property var _uploadHistory: [] + property real _uploadSpeed: 0 + property real _uploadTotal: 0 + + // History of speeds for sparkline (most recent at end) + readonly property var downloadHistory: _downloadHistory + + // Current speeds in bytes per second + readonly property real downloadSpeed: _downloadSpeed + + // Total bytes transferred since tracking started + readonly property real downloadTotal: _downloadTotal + readonly property int historyLength: 30 + property int refCount: 0 + readonly property var uploadHistory: _uploadHistory + readonly property real uploadSpeed: _uploadSpeed + readonly property real uploadTotal: _uploadTotal + + function formatBytes(bytes: real): var { + // Handle negative or invalid values + if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { + return { + value: 0, + unit: "B/s" + }; + } + + if (bytes < 1024) { + return { + value: bytes, + unit: "B/s" + }; + } else if (bytes < 1024 * 1024) { + return { + value: bytes / 1024, + unit: "KB/s" + }; + } else if (bytes < 1024 * 1024 * 1024) { + return { + value: bytes / (1024 * 1024), + unit: "MB/s" + }; + } else { + return { + value: bytes / (1024 * 1024 * 1024), + unit: "GB/s" + }; + } + } + + function formatBytesTotal(bytes: real): var { + // Handle negative or invalid values + if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) { + return { + value: 0, + unit: "B" + }; + } + + if (bytes < 1024) { + return { + value: bytes, + unit: "B" + }; + } else if (bytes < 1024 * 1024) { + return { + value: bytes / 1024, + unit: "KB" + }; + } else if (bytes < 1024 * 1024 * 1024) { + return { + value: bytes / (1024 * 1024), + unit: "MB" + }; + } else { + return { + value: bytes / (1024 * 1024 * 1024), + unit: "GB" + }; + } + } + + function parseNetDev(content: string): var { + const lines = content.split("\n"); + let totalRx = 0; + let totalTx = 0; + + for (let i = 2; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) + continue; + + const parts = line.split(/\s+/); + if (parts.length < 10) + continue; + + const iface = parts[0].replace(":", ""); + // Skip loopback interface + if (iface === "lo") + continue; + + const rxBytes = parseFloat(parts[1]) || 0; + const txBytes = parseFloat(parts[9]) || 0; + + totalRx += rxBytes; + totalTx += txBytes; + } + + return { + rx: totalRx, + tx: totalTx + }; + } + + FileView { + id: netDevFile + + path: "/proc/net/dev" + } + + Timer { + interval: Config.dashboard.resourceUpdateInterval + repeat: true + running: root.refCount > 0 + triggeredOnStart: true + + onTriggered: { + netDevFile.reload(); + const content = netDevFile.text(); + if (!content) + return; + + const data = root.parseNetDev(content); + const now = Date.now(); + + if (!root._initialized) { + root._initialRxBytes = data.rx; + root._initialTxBytes = data.tx; + root._prevRxBytes = data.rx; + root._prevTxBytes = data.tx; + root._prevTimestamp = now; + root._initialized = true; + return; + } + + const timeDelta = (now - root._prevTimestamp) / 1000; // seconds + if (timeDelta > 0) { + // Calculate byte deltas + let rxDelta = data.rx - root._prevRxBytes; + let txDelta = data.tx - root._prevTxBytes; + + // Handle counter overflow (when counters wrap around from max to 0) + // This happens when counters exceed 32-bit or 64-bit limits + if (rxDelta < 0) { + // Counter wrapped around - assume 64-bit counter + rxDelta += Math.pow(2, 64); + } + if (txDelta < 0) { + txDelta += Math.pow(2, 64); + } + + // Calculate speeds + root._downloadSpeed = rxDelta / timeDelta; + root._uploadSpeed = txDelta / timeDelta; + + const maxHistory = root.historyLength + 1; + + if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) { + let newDownHist = root._downloadHistory.slice(); + newDownHist.push(root._downloadSpeed); + if (newDownHist.length > maxHistory) { + newDownHist.shift(); + } + root._downloadHistory = newDownHist; + } + + if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) { + let newUpHist = root._uploadHistory.slice(); + newUpHist.push(root._uploadSpeed); + if (newUpHist.length > maxHistory) { + newUpHist.shift(); + } + root._uploadHistory = newUpHist; + } + } + + // Calculate totals with overflow handling + let downTotal = data.rx - root._initialRxBytes; + let upTotal = data.tx - root._initialTxBytes; + + // Handle counter overflow for totals + if (downTotal < 0) { + downTotal += Math.pow(2, 64); + } + if (upTotal < 0) { + upTotal += Math.pow(2, 64); + } + + root._downloadTotal = downTotal; + root._uploadTotal = upTotal; + + root._prevRxBytes = data.rx; + root._prevTxBytes = data.tx; + root._prevTimestamp = now; + } + } +} diff --git a/Helpers/SystemUsage.qml b/Helpers/SystemUsage.qml index d7a568b..d9234e9 100644 --- a/Helpers/SystemUsage.qml +++ b/Helpers/SystemUsage.qml @@ -9,10 +9,15 @@ Singleton { id: root property string autoGpuType: "NONE" + property string cpuName: "" property real cpuPerc property real cpuTemp + + // Individual disks: Array of { mount, used, total, free, perc } + property var disks: [] property real gpuMemTotal: 0 property real gpuMemUsed + property string gpuName: "" property real gpuPerc property real gpuTemp readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType @@ -22,9 +27,23 @@ Singleton { property real memTotal property real memUsed property int refCount - property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0 - property real storageTotal - property real storageUsed + readonly property real storagePerc: { + let totalUsed = 0; + let totalSize = 0; + for (const disk of disks) { + totalUsed += disk.used; + totalSize += disk.total; + } + return totalSize > 0 ? totalUsed / totalSize : 0; + } + + function cleanCpuName(name: string): string { + return name.replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/CPU/gi, "").replace(/\d+th Gen /gi, "").replace(/\d+nd Gen /gi, "").replace(/\d+rd Gen /gi, "").replace(/\d+st Gen /gi, "").replace(/Core /gi, "").replace(/Processor/gi, "").replace(/\s+/g, " ").trim(); + } + + function cleanGpuName(name: string): string { + return name.replace(/NVIDIA GeForce /gi, "").replace(/NVIDIA /gi, "").replace(/AMD Radeon /gi, "").replace(/AMD /gi, "").replace(/Intel /gi, "").replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/Graphics/gi, "").replace(/\s+/g, " ").trim(); + } function formatKib(kib: real): var { const mib = 1024; @@ -53,7 +72,7 @@ Singleton { } Timer { - interval: 3000 + interval: Config.dashboard.resourceUpdateInterval repeat: true running: root.refCount > 0 triggeredOnStart: true @@ -67,6 +86,18 @@ Singleton { } } + FileView { + id: cpuinfoInit + + path: "/proc/cpuinfo" + + onLoaded: { + const nameMatch = text().match(/model name\s*:\s*(.+)/); + if (nameMatch) + root.cpuName = root.cleanCpuName(nameMatch[1]); + } + } + FileView { id: stat @@ -104,42 +135,116 @@ Singleton { Process { id: storage - command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $1, $3, $4}'"] + command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"] stdout: StdioCollector { onStreamFinished: { - const deviceMap = new Map(); + const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal } + const lines = text.trim().split("\n"); - for (const line of text.trim().split("\n")) { + for (const line of lines) { if (line.trim() === "") continue; + const nameMatch = line.match(/NAME="([^"]+)"/); + const sizeMatch = line.match(/SIZE="([^"]+)"/); + const typeMatch = line.match(/TYPE="([^"]+)"/); + const fsusedMatch = line.match(/FSUSED="([^"]*)"/); + const fssizeMatch = line.match(/FSSIZE="([^"]*)"/); - const parts = line.trim().split(/\s+/); - if (parts.length >= 3) { - const device = parts[0]; - const used = parseInt(parts[1], 10) || 0; - const avail = parseInt(parts[2], 10) || 0; + if (!nameMatch || !typeMatch) + continue; - // Only keep the entry with the largest total space for each device - if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) { - deviceMap.set(device, { - used: used, - avail: avail - }); + const name = nameMatch[1]; + const type = typeMatch[1]; + const size = parseInt(sizeMatch?.[1] || "0", 10); + const fsused = parseInt(fsusedMatch?.[1] || "0", 10); + const fssize = parseInt(fssizeMatch?.[1] || "0", 10); + + if (type === "disk") { + // Skip zram (swap) devices + if (name.startsWith("zram")) + continue; + + // Initialize disk entry + if (!diskMap[name]) { + diskMap[name] = { + name: name, + totalSize: size, + used: 0, + fsTotal: 0 + }; + } + } else if (type === "part") { + // Find parent disk (remove trailing numbers/p+numbers) + let parentDisk = name.replace(/p?\d+$/, ""); + // For nvme devices like nvme0n1p1, parent is nvme0n1 + if (name.match(/nvme\d+n\d+p\d+/)) + parentDisk = name.replace(/p\d+$/, ""); + + // Aggregate partition usage to parent disk + if (diskMap[parentDisk]) { + diskMap[parentDisk].used += fsused; + diskMap[parentDisk].fsTotal += fssize; } } } + const diskList = []; let totalUsed = 0; - let totalAvail = 0; + let totalSize = 0; - for (const [device, stats] of deviceMap) { - totalUsed += stats.used; - totalAvail += stats.avail; + for (const diskName of Object.keys(diskMap).sort()) { + const disk = diskMap[diskName]; + // Use filesystem total if available, otherwise use disk size + const total = disk.fsTotal > 0 ? disk.fsTotal : disk.totalSize; + const used = disk.used; + const perc = total > 0 ? used / total : 0; + + // Convert bytes to KiB for consistency with formatKib + diskList.push({ + mount: disk.name // Using 'mount' property for compatibility + , + used: used / 1024, + total: total / 1024, + free: (total - used) / 1024, + perc: perc + }); + + totalUsed += used; + totalSize += total; } - root.storageUsed = totalUsed; - root.storageTotal = totalUsed + totalAvail; + root.disks = diskList; + } + } + } + + Process { + id: gpuNameDetect + + command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || lspci 2>/dev/null | grep -i 'vga\\|3d\\|display' | head -1"] + running: true + + stdout: StdioCollector { + onStreamFinished: { + const output = text.trim(); + if (!output) + return; + + // Check if it's from nvidia-smi (clean GPU name) + if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) { + root.gpuName = root.cleanGpuName(output); + } else { + // Parse lspci output: extract name from brackets or after colon + const bracketMatch = output.match(/\[([^\]]+)\]/); + if (bracketMatch) { + root.gpuName = root.cleanGpuName(bracketMatch[1]); + } else { + const colonMatch = output.match(/:\s*(.+)/); + if (colonMatch) + root.gpuName = root.cleanGpuName(colonMatch[1]); + } + } } } } diff --git a/Modules/Bar/BarLoader.qml b/Modules/Bar/BarLoader.qml index 85522ac..f72a32a 100644 --- a/Modules/Bar/BarLoader.qml +++ b/Modules/Bar/BarLoader.qml @@ -23,13 +23,13 @@ RowLayout { function checkPopout(x: real): void { const ch = childAt(x, 2) as WrappedLoader; - if (!ch) { - if (!popouts.currentName.includes("traymenu")) + if (!ch || ch?.id === "spacer") { + if (!popouts.currentName.startsWith("traymenu")) popouts.hasCurrent = false; return; } - if (visibilities.sidebar || visibilities.dashboard) + if (visibilities.sidebar || visibilities.dashboard || visibilities.resources) return; const id = ch.id; @@ -41,26 +41,6 @@ RowLayout { popouts.currentName = "audio"; popouts.currentCenter = Qt.binding(() => item.mapToItem(root, itemWidth / 2, 0).x); popouts.hasCurrent = true; - } else if (id === "resources" && Config.barConfig.popouts.resources) { - popouts.currentName = "resources"; - popouts.currentCenter = Qt.binding(() => item.mapToItem(root, itemWidth / 2, 0).x); - popouts.hasCurrent = true; - } else if (id === "tray" && Config.barConfig.popouts.tray) { - const index = Math.floor(((x - top) / item.implicitWidth) * item.items.count); - const trayItem = item.items.itemAt(index); - if (trayItem) { - // popouts.currentName = `traymenu${ index }`; - // popouts.currentCenter = Qt.binding( () => trayItem.mapToItem( root, trayItem.implicitWidth / 2, 0 ).x ); - // popouts.hasCurrent = true; - } else { - // popouts.hasCurrent = false; - } - } else if (id === "clock" && Config.barConfig.popouts.clock) { - // Calendar.displayYear = new Date().getFullYear(); - // Calendar.displayMonth = new Date().getMonth(); - // popouts.currentName = "calendar"; - // popouts.currentCenter = Qt.binding( () => item.mapToItem( root, itemWidth / 2, 0 ).x ); - // popouts.hasCurrent = true; } else if (id === "network" && Config.barConfig.popouts.network) { popouts.currentName = "network"; popouts.currentCenter = Qt.binding(() => item.mapToItem(root, itemWidth / 2, 0).x); @@ -142,6 +122,7 @@ RowLayout { delegate: WrappedLoader { sourceComponent: Resources { + visibilities: root.visibilities } } } diff --git a/Modules/Content.qml b/Modules/Content.qml index 1306768..80e16d0 100644 --- a/Modules/Content.qml +++ b/Modules/Content.qml @@ -33,14 +33,6 @@ Item { } } - Popout { - name: "resources" - - sourceComponent: ResourcePopout { - wrapper: root.wrapper - } - } - Repeater { model: ScriptModel { values: [...SystemTray.items.values] diff --git a/Modules/Launcher/Items/WallpaperItem.qml b/Modules/Launcher/Items/WallpaperItem.qml index 6620cb9..65d9418 100644 --- a/Modules/Launcher/Items/WallpaperItem.qml +++ b/Modules/Launcher/Items/WallpaperItem.qml @@ -60,7 +60,7 @@ Item { color: DynamicColors.tPalette.m3surfaceContainer implicitHeight: implicitWidth / 16 * 9 implicitWidth: Config.launcher.sizes.wallpaperWidth - radius: Appearance.rounding.normal + radius: Appearance.rounding.small y: Appearance.padding.large MaterialIcon { diff --git a/Modules/Launcher/WallpaperList.qml b/Modules/Launcher/WallpaperList.qml index b6a6a7b..94d6ab1 100644 --- a/Modules/Launcher/WallpaperList.qml +++ b/Modules/Launcher/WallpaperList.qml @@ -11,7 +11,7 @@ PathView { id: root required property var content - readonly property int itemWidth: Config.launcher.sizes.wallpaperWidth * 0.8 + Appearance.padding.larger * 2 + readonly property int itemWidth: Config.launcher.sizes.wallpaperWidth * 0.9 + Appearance.padding.larger * 2 readonly property int numItems: { const screen = QsWindow.window?.screen; if (!screen) @@ -20,8 +20,6 @@ PathView { // Screen width - 4x outer rounding - 2x max side thickness (cause centered) const barMargins = panels.bar.implicitWidth; let outerMargins = 0; - if (panels.popouts.hasCurrent && panels.popouts.currentCenter + panels.popouts.nonAnimHeight / 2 > screen.height - content.implicitHeight) - outerMargins = panels.popouts.nonAnimWidth; if ((visibilities.utilities || visibilities.sidebar) && panels.utilities.implicitWidth > outerMargins) outerMargins = panels.utilities.implicitWidth; const maxWidth = screen.width - Config.barConfig.rounding * 4 - (barMargins + outerMargins) * 2; diff --git a/Modules/ResourcePopout.qml b/Modules/ResourcePopout-old.qml similarity index 100% rename from Modules/ResourcePopout.qml rename to Modules/ResourcePopout-old.qml diff --git a/Modules/Resources.qml b/Modules/Resources.qml index ba0babf..8707508 100644 --- a/Modules/Resources.qml +++ b/Modules/Resources.qml @@ -12,6 +12,8 @@ import qs.Components Item { id: root + required property PersistentProperties visibilities + clip: true implicitHeight: 34 implicitWidth: rowLayout.implicitWidth + Appearance.padding.small * 2 @@ -28,6 +30,10 @@ Item { right: parent.right verticalCenter: parent.verticalCenter } + + StateLayer { + onClicked: root.visibilities.resources = !root.visibilities.resources + } } RowLayout { diff --git a/Modules/Resources/Background.qml b/Modules/Resources/Background.qml new file mode 100644 index 0000000..bb55258 --- /dev/null +++ b/Modules/Resources/Background.qml @@ -0,0 +1,66 @@ +import qs.Components +import qs.Config +import QtQuick +import QtQuick.Shapes + +ShapePath { + id: root + + readonly property bool flatten: wrapper.height < rounding * 2 + readonly property real rounding: Appearance.rounding.normal + readonly property real roundingY: flatten ? wrapper.height / 2 : rounding + required property Wrapper wrapper + + fillColor: DynamicColors.palette.m3surface + strokeWidth: -1 + + Behavior on fillColor { + CAnim { + } + } + + PathArc { + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + relativeX: root.rounding + relativeY: root.roundingY + } + + PathLine { + relativeX: 0 + relativeY: root.wrapper.height - root.roundingY * 2 + } + + PathArc { + direction: PathArc.Counterclockwise + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + relativeX: root.rounding + relativeY: root.roundingY + } + + PathLine { + relativeX: root.wrapper.width - root.rounding * 2 + relativeY: 0 + } + + PathArc { + direction: PathArc.Counterclockwise + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + relativeX: root.rounding + relativeY: -root.roundingY + } + + PathLine { + relativeX: 0 + relativeY: -(root.wrapper.height - root.roundingY * 2) + } + + PathArc { + radiusX: root.rounding + radiusY: Math.min(root.rounding, root.wrapper.height) + relativeX: root.rounding + relativeY: -root.roundingY + } +} diff --git a/Modules/Resources/Content.qml b/Modules/Resources/Content.qml new file mode 100644 index 0000000..432edc6 --- /dev/null +++ b/Modules/Resources/Content.qml @@ -0,0 +1,975 @@ +import Quickshell +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Services.UPower +import qs.Components +import qs.Config +import qs.Helpers + +Item { + id: root + + readonly property int minWidth: 400 + 400 + Appearance.spacing.normal + 120 + Appearance.padding.large * 2 + readonly property real nonAnimHeight: (placeholder.visible ? placeholder.height : content.implicitHeight) + Appearance.padding.normal * 2 + readonly property real nonAnimWidth: Math.max(minWidth, content.implicitWidth) + Appearance.padding.normal * 2 + required property real padding + required property PersistentProperties visibilities + + function displayTemp(temp: real): string { + return `${Math.ceil(temp)}°C`; + } + + implicitHeight: nonAnimHeight + implicitWidth: nonAnimWidth + + CustomRect { + id: placeholder + + anchors.centerIn: parent + color: DynamicColors.tPalette.m3surfaceContainer + height: 350 + radius: Appearance.rounding.large - 10 + visible: false + width: 400 + + ColumnLayout { + anchors.centerIn: parent + spacing: Appearance.spacing.normal + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.extraLarge * 2 + text: "tune" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurface + font.pointSize: Appearance.font.size.large + text: qsTr("No widgets enabled") + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: qsTr("Enable widgets in dashboard settings") + } + } + } + + RowLayout { + id: content + + anchors.left: parent.left + anchors.leftMargin: root.padding + anchors.right: parent.right + anchors.rightMargin: root.padding + anchors.verticalCenter: parent.verticalCenter + spacing: Appearance.spacing.normal + visible: !placeholder.visible + + Ref { + service: SystemUsage + } + + ColumnLayout { + id: mainColumn + + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + visible: true + + HeroCard { + Layout.fillWidth: true + Layout.minimumWidth: 400 + Layout.preferredHeight: 150 + accentColor: DynamicColors.palette.m3primary + icon: "memory" + mainLabel: qsTr("Usage") + mainValue: `${Math.round(SystemUsage.cpuPerc * 100)}%` + secondaryLabel: qsTr("Temp") + secondaryValue: root.displayTemp(SystemUsage.cpuTemp) + temperature: SystemUsage.cpuTemp + title: SystemUsage.cpuName ? `CPU - ${SystemUsage.cpuName}` : qsTr("CPU") + usage: SystemUsage.cpuPerc + visible: Config.dashboard.performance.showCpu + } + + HeroCard { + Layout.fillWidth: true + Layout.minimumWidth: 400 + Layout.preferredHeight: 150 + accentColor: DynamicColors.palette.m3secondary + icon: "desktop_windows" + mainLabel: qsTr("Usage") + mainValue: `${Math.round(SystemUsage.gpuPerc * 100)}%` + secondaryLabel: qsTr("Temp") + secondaryValue: root.displayTemp(SystemUsage.gpuTemp) + temperature: SystemUsage.gpuTemp + title: SystemUsage.gpuName ? `GPU - ${SystemUsage.gpuName}` : qsTr("GPU") + usage: SystemUsage.gpuPerc + visible: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE" + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + visible: Config.dashboard.performance.showMemory || Config.dashboard.performance.showStorage || Config.dashboard.performance.showNetwork + + GaugeCard { + Layout.fillWidth: !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork + Layout.minimumWidth: 250 + Layout.preferredHeight: 220 + accentColor: DynamicColors.palette.m3tertiary + icon: "memory_alt" + percentage: SystemUsage.memPerc + subtitle: { + const usedFmt = SystemUsage.formatKib(SystemUsage.memUsed); + const totalFmt = SystemUsage.formatKib(SystemUsage.memTotal); + return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; + } + title: qsTr("Memory") + visible: Config.dashboard.performance.showMemory + } + + StorageGaugeCard { + Layout.fillWidth: !Config.dashboard.performance.showNetwork + Layout.minimumWidth: 250 + Layout.preferredHeight: 220 + visible: Config.dashboard.performance.showStorage + } + + NetworkCard { + Layout.fillWidth: true + Layout.minimumWidth: 200 + Layout.preferredHeight: 220 + visible: Config.dashboard.performance.showNetwork + } + } + } + + BatteryTank { + Layout.preferredHeight: mainColumn.implicitHeight + Layout.preferredWidth: 120 + visible: UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery + } + } + + component BatteryTank: CustomClippingRect { + id: batteryTank + + property color accentColor: DynamicColors.palette.m3primary + property real animatedPercentage: 0 + property bool isCharging: UPower.displayDevice.state === UPowerDeviceState.Charging + property real percentage: UPower.displayDevice.percentage + + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.large - 10 + + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + + Component.onCompleted: animatedPercentage = percentage + onPercentageChanged: animatedPercentage = percentage + + // Background Fill + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + color: Qt.alpha(batteryTank.accentColor, 0.15) + height: parent.height * batteryTank.animatedPercentage + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + // Header Section + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + color: batteryTank.accentColor + font.pointSize: Appearance.font.size.large + text: { + if (!UPower.displayDevice.isLaptopBattery) { + if (PowerProfiles.profile === PowerProfile.PowerSaver) + return "energy_savings_leaf"; + + if (PowerProfiles.profile === PowerProfile.Performance) + return "rocket_launch"; + + return "balance"; + } + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return "battery_full"; + + const perc = UPower.displayDevice.percentage; + const charging = [UPowerDeviceState.Charging, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state); + if (perc >= 0.99) + return "battery_full"; + + let level = Math.floor(perc * 7); + if (charging && (level === 4 || level === 1)) + level--; + + return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`; + } + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3onSurface + font.pointSize: Appearance.font.size.normal + text: qsTr("Battery") + } + } + + Item { + Layout.fillHeight: true + } + + // Bottom Info Section + ColumnLayout { + Layout.fillWidth: true + spacing: -4 + + CustomText { + Layout.alignment: Qt.AlignRight + color: batteryTank.accentColor + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + text: `${Math.round(batteryTank.percentage * 100)}%` + } + + CustomText { + Layout.alignment: Qt.AlignRight + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.smaller + text: { + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return qsTr("Full"); + + if (batteryTank.isCharging) + return qsTr("Charging"); + + const s = UPower.displayDevice.timeToEmpty; + if (s === 0) + return qsTr("..."); + + const hr = Math.floor(s / 3600); + const min = Math.floor((s % 3600) / 60); + if (hr > 0) + return `${hr}h ${min}m`; + + return `${min}m`; + } + } + } + } + } + component CardHeader: RowLayout { + property color accentColor: DynamicColors.palette.m3primary + property string icon + property string title + + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + color: parent.accentColor + fill: 1 + font.pointSize: Appearance.spacing.large + text: parent.icon + } + + CustomText { + Layout.fillWidth: true + elide: Text.ElideRight + font.pointSize: Appearance.font.size.normal + text: parent.title + } + } + component GaugeCard: CustomRect { + id: gaugeCard + + property color accentColor: DynamicColors.palette.m3primary + property real animatedPercentage: 0 + readonly property real arcStartAngle: 0.75 * Math.PI + readonly property real arcSweep: 1.5 * Math.PI + property string icon + property real percentage: 0 + property string subtitle + property string title + + clip: true + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.large - 10 + + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + + Component.onCompleted: animatedPercentage = percentage + onPercentageChanged: animatedPercentage = percentage + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller + + CardHeader { + accentColor: gaugeCard.accentColor + icon: gaugeCard.icon + title: gaugeCard.title + } + + Item { + Layout.fillHeight: true + Layout.fillWidth: true + + Canvas { + id: gaugeCanvas + + anchors.centerIn: parent + height: width + width: Math.min(parent.width, parent.height) + + Component.onCompleted: requestPaint() + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const cx = width / 2; + const cy = height / 2; + const radius = (Math.min(width, height) - 12) / 2; + const lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); + ctx.stroke(); + if (gaugeCard.animatedPercentage > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep * gaugeCard.animatedPercentage); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = gaugeCard.accentColor; + ctx.stroke(); + } + } + + Connections { + function onAnimatedPercentageChanged() { + gaugeCanvas.requestPaint(); + } + + target: gaugeCard + } + + Connections { + function onPaletteChanged() { + gaugeCanvas.requestPaint(); + } + + target: DynamicColors + } + } + + CustomText { + anchors.centerIn: parent + color: gaugeCard.accentColor + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + text: `${Math.round(gaugeCard.percentage * 100)}%` + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.smaller + text: gaugeCard.subtitle + } + } + } + component HeroCard: CustomClippingRect { + id: heroCard + + property color accentColor: DynamicColors.palette.m3primary + property real animatedTemp: 0 + property real animatedUsage: 0 + property string icon + property string mainLabel + property string mainValue + readonly property real maxTemp: 100 + property string secondaryLabel + property string secondaryValue + readonly property real tempProgress: Math.min(1, Math.max(0, temperature / maxTemp)) + property real temperature: 0 + property string title + property real usage: 0 + + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.large - 10 + + Behavior on animatedTemp { + Anim { + duration: Appearance.anim.durations.large + } + } + Behavior on animatedUsage { + Anim { + duration: Appearance.anim.durations.large + } + } + + Component.onCompleted: { + animatedUsage = usage; + animatedTemp = tempProgress; + } + onTempProgressChanged: animatedTemp = tempProgress + onUsageChanged: animatedUsage = usage + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.top: parent.top + color: Qt.alpha(heroCard.accentColor, 0.15) + width: parent.width * heroCard.animatedUsage + } + + ColumnLayout { + anchors.bottomMargin: Appearance.padding.normal + anchors.fill: parent + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + anchors.topMargin: Appearance.padding.normal + spacing: Appearance.spacing.small + + CardHeader { + accentColor: heroCard.accentColor + icon: heroCard.icon + title: heroCard.title + } + + RowLayout { + Layout.fillHeight: true + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + Column { + Layout.alignment: Qt.AlignBottom + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Row { + spacing: Appearance.spacing.small + + CustomText { + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + text: heroCard.secondaryValue + } + + CustomText { + anchors.baseline: parent.children[0].baseline + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: heroCard.secondaryLabel + } + } + + ProgressBar { + bgColor: Qt.alpha(heroCard.accentColor, 0.2) + fgColor: heroCard.accentColor + height: 6 + value: heroCard.tempProgress + width: parent.width * 0.5 + } + } + + Item { + Layout.fillWidth: true + } + } + } + + Column { + anchors.margins: Appearance.padding.large + anchors.right: parent.right + anchors.rightMargin: 32 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + anchors.right: parent.right + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + text: heroCard.mainLabel + } + + CustomText { + anchors.right: parent.right + color: heroCard.accentColor + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + text: heroCard.mainValue + } + } + } + component NetworkCard: CustomRect { + id: networkCard + + property color accentColor: DynamicColors.palette.m3primary + + clip: true + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.large - 10 + + Ref { + service: NetworkUsage + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + CardHeader { + accentColor: networkCard.accentColor + icon: "swap_vert" + title: qsTr("Network") + } + + // Sparkline graph + Item { + Layout.fillHeight: true + Layout.fillWidth: true + + Canvas { + id: sparklineCanvas + + property int _lastTickCount: -1 + property int _tickCount: 0 + property var downHistory: NetworkUsage.downloadHistory + property real slideProgress: 0 + property real smoothMax: targetMax + property real targetMax: 1024 + property var upHistory: NetworkUsage.uploadHistory + + function checkAndAnimate(): void { + const currentLength = (downHistory || []).length; + if (currentLength > 0 && _tickCount !== _lastTickCount) { + _lastTickCount = _tickCount; + updateMax(); + } + } + + function updateMax(): void { + const downHist = downHistory || []; + const upHist = upHistory || []; + const allValues = downHist.concat(upHist); + targetMax = Math.max(...allValues, 1024); + requestPaint(); + } + + anchors.fill: parent + + NumberAnimation on slideProgress { + duration: Config.dashboard.resourceUpdateInterval + from: 0 + loops: Animation.Infinite + running: true + to: 1 + } + Behavior on smoothMax { + Anim { + duration: Appearance.anim.durations.large + } + } + + Component.onCompleted: updateMax() + onDownHistoryChanged: checkAndAnimate() + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const w = width; + const h = height; + const downHist = downHistory || []; + const upHist = upHistory || []; + if (downHist.length < 2 && upHist.length < 2) + return; + + const maxVal = smoothMax; + + const drawLine = (history, color, fillAlpha) => { + if (history.length < 2) + return; + + const len = history.length; + const stepX = w / (NetworkUsage.historyLength - 1); + const startX = w - (len - 1) * stepX - stepX * slideProgress + stepX; + ctx.beginPath(); + ctx.moveTo(startX, h - (history[0] / maxVal) * h); + for (let i = 1; i < len; i++) { + const x = startX + i * stepX; + const y = h - (history[i] / maxVal) * h; + ctx.lineTo(x, y); + } + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.lineTo(startX + (len - 1) * stepX, h); + ctx.lineTo(startX, h); + ctx.closePath(); + ctx.fillStyle = Qt.rgba(Qt.color(color).r, Qt.color(color).g, Qt.color(color).b, fillAlpha); + ctx.fill(); + }; + + drawLine(upHist, DynamicColors.palette.m3secondary.toString(), 0.15); + drawLine(downHist, DynamicColors.palette.m3tertiary.toString(), 0.2); + } + onSlideProgressChanged: requestPaint() + onSmoothMaxChanged: requestPaint() + onUpHistoryChanged: checkAndAnimate() + + Connections { + function onPaletteChanged() { + sparklineCanvas.requestPaint(); + } + + target: DynamicColors + } + + Timer { + interval: Config.dashboard.resourceUpdateInterval + repeat: true + running: true + + onTriggered: sparklineCanvas._tickCount++ + } + } + + // "No data" placeholder + CustomText { + anchors.centerIn: parent + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + opacity: 0.6 + text: qsTr("Collecting data...") + visible: NetworkUsage.downloadHistory.length < 2 + } + } + + // Download row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + color: DynamicColors.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + text: "download" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: qsTr("Download") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + } + } + + // Upload row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.normal + text: "upload" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: qsTr("Upload") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3secondary + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + } + } + + // Session totals + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + text: "history" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: qsTr("Total") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.small + text: { + const down = NetworkUsage.formatBytesTotal(NetworkUsage.downloadTotal ?? 0); + const up = NetworkUsage.formatBytesTotal(NetworkUsage.uploadTotal ?? 0); + return (down && up) ? `↓${down.value.toFixed(1)}${down.unit} ↑${up.value.toFixed(1)}${up.unit}` : "↓0.0B ↑0.0B"; + } + } + } + } + } + component ProgressBar: CustomRect { + id: progressBar + + property real animatedValue: 0 + property color bgColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) + property color fgColor: DynamicColors.palette.m3primary + property real value: 0 + + color: bgColor + radius: Appearance.rounding.full + + Behavior on animatedValue { + Anim { + duration: Appearance.anim.durations.large + } + } + + Component.onCompleted: animatedValue = value + onValueChanged: animatedValue = value + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.top: parent.top + color: progressBar.fgColor + radius: Appearance.rounding.full + width: parent.width * progressBar.animatedValue + } + } + component StorageGaugeCard: CustomRect { + id: storageGaugeCard + + property color accentColor: DynamicColors.palette.m3secondary + property real animatedPercentage: 0 + readonly property real arcStartAngle: 0.75 * Math.PI + readonly property real arcSweep: 1.5 * Math.PI + readonly property var currentDisk: SystemUsage.disks.length > 0 ? SystemUsage.disks[currentDiskIndex] : null + property int currentDiskIndex: 0 + property int diskCount: 0 + + clip: true + color: DynamicColors.tPalette.m3surfaceContainer + radius: Appearance.rounding.large - 10 + + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + + Component.onCompleted: { + diskCount = SystemUsage.disks.length; + if (currentDisk) + animatedPercentage = currentDisk.perc; + } + onCurrentDiskChanged: { + if (currentDisk) + animatedPercentage = currentDisk.perc; + } + + // Update diskCount and animatedPercentage when disks data changes + Connections { + function onDisksChanged() { + if (SystemUsage.disks.length !== storageGaugeCard.diskCount) + storageGaugeCard.diskCount = SystemUsage.disks.length; + + // Update animated percentage when disk data refreshes + if (storageGaugeCard.currentDisk) + storageGaugeCard.animatedPercentage = storageGaugeCard.currentDisk.perc; + } + + target: SystemUsage + } + + MouseArea { + anchors.fill: parent + + onWheel: wheel => { + if (wheel.angleDelta.y > 0) + storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex - 1 + storageGaugeCard.diskCount) % storageGaugeCard.diskCount; + else if (wheel.angleDelta.y < 0) + storageGaugeCard.currentDiskIndex = (storageGaugeCard.currentDiskIndex + 1) % storageGaugeCard.diskCount; + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller + + CardHeader { + accentColor: storageGaugeCard.accentColor + icon: "hard_disk" + title: { + const base = qsTr("Storage"); + if (!storageGaugeCard.currentDisk) + return base; + + return `${base} - ${storageGaugeCard.currentDisk.mount}`; + } + + // Scroll hint icon + MaterialIcon { + ToolTip.delay: 500 + ToolTip.text: qsTr("Scroll to switch disks") + ToolTip.visible: hintHover.hovered + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + opacity: 0.7 + text: "unfold_more" + visible: storageGaugeCard.diskCount > 1 + + HoverHandler { + id: hintHover + + } + } + } + + Item { + Layout.fillHeight: true + Layout.fillWidth: true + + Canvas { + id: storageGaugeCanvas + + anchors.centerIn: parent + height: width + width: Math.min(parent.width, parent.height) + + Component.onCompleted: requestPaint() + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const cx = width / 2; + const cy = height / 2; + const radius = (Math.min(width, height) - 12) / 2; + const lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2); + ctx.stroke(); + if (storageGaugeCard.animatedPercentage > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep * storageGaugeCard.animatedPercentage); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = storageGaugeCard.accentColor; + ctx.stroke(); + } + } + + Connections { + function onAnimatedPercentageChanged() { + storageGaugeCanvas.requestPaint(); + } + + target: storageGaugeCard + } + + Connections { + function onPaletteChanged() { + storageGaugeCanvas.requestPaint(); + } + + target: DynamicColors + } + } + + CustomText { + anchors.centerIn: parent + color: storageGaugeCard.accentColor + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + text: storageGaugeCard.currentDisk ? `${Math.round(storageGaugeCard.currentDisk.perc * 100)}%` : "—" + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.smaller + text: { + if (!storageGaugeCard.currentDisk) + return "—"; + + const usedFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.used); + const totalFmt = SystemUsage.formatKib(storageGaugeCard.currentDisk.total); + return `${usedFmt.value.toFixed(1)} / ${Math.floor(totalFmt.value)} ${totalFmt.unit}`; + } + } + } + } +} diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml new file mode 100644 index 0000000..a1f750e --- /dev/null +++ b/Modules/Resources/Wrapper.qml @@ -0,0 +1,86 @@ +pragma ComponentBehavior: Bound + +import Quickshell +import QtQuick +import qs.Components +import qs.Config + +Item { + id: root + + readonly property real nonAnimHeight: state === "visible" ? (content.item?.nonAnimHeight ?? 0) : 0 + required property PersistentProperties visibilities + + implicitHeight: 0 + implicitWidth: content.implicitWidth + visible: height > 0 + + states: State { + name: "visible" + when: root.visibilities.resources + + PropertyChanges { + root.implicitHeight: content.implicitHeight + } + } + transitions: [ + Transition { + from: "" + to: "visible" + + Anim { + duration: MaterialEasing.expressiveEffectsTime + easing.bezierCurve: MaterialEasing.expressiveEffects + property: "implicitHeight" + target: root + } + }, + Transition { + from: "visible" + to: "" + + Anim { + easing.bezierCurve: MaterialEasing.expressiveEffects + property: "implicitHeight" + target: root + } + } + ] + + onStateChanged: { + if (state === "visible" && timer.running) { + timer.triggered(); + timer.stop(); + } + } + + Timer { + id: timer + + interval: Appearance.anim.durations.extraLarge + running: true + + onTriggered: { + content.active = Qt.binding(() => (root.visibilities.resources) || root.visible); + content.visible = true; + } + } + + CustomClippingRect { + anchors.fill: parent + + Loader { + id: content + + active: true + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + visible: false + + sourceComponent: Content { + padding: Appearance.padding.normal + visibilities: root.visibilities + } + } + } +} diff --git a/Modules/Wallpaper/Wallpaper.qml b/Modules/Wallpaper/Wallpaper.qml index 705e6d0..fa77580 100644 --- a/Modules/Wallpaper/Wallpaper.qml +++ b/Modules/Wallpaper/Wallpaper.qml @@ -16,7 +16,7 @@ Loader { required property var modelData WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Bottom + WlrLayershell.layer: WlrLayer.Background WlrLayershell.namespace: "ZShell-Wallpaper" color: "transparent" screen: modelData