From a27a6deddfc9c770d1aa2556835d019c6388d7ff Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Fri, 7 Nov 2025 12:56:44 +0100 Subject: [PATCH] overlay: add resource monitor widget --- .../quickshell/ii/modules/common/Config.qml | 1 + .../ii/modules/common/Persistent.qml | 7 + .../ii/modules/common/widgets/Graph.qml | 51 +++++++ .../ii/modules/overlay/OverlayContext.qml | 5 +- .../overlay/OverlayWidgetDelegateChooser.qml | 2 + .../ii/modules/overlay/recorder/Recorder.qml | 1 - .../modules/overlay/resources/Resources.qml | 134 ++++++++++++++++++ .../quickshell/ii/services/ResourceUsage.qml | 83 +++++++++-- 8 files changed, 272 insertions(+), 12 deletions(-) create mode 100644 dots/.config/quickshell/ii/modules/common/widgets/Graph.qml create mode 100644 dots/.config/quickshell/ii/modules/overlay/resources/Resources.qml diff --git a/dots/.config/quickshell/ii/modules/common/Config.qml b/dots/.config/quickshell/ii/modules/common/Config.qml index 55c9c94a8..d064d2533 100644 --- a/dots/.config/quickshell/ii/modules/common/Config.qml +++ b/dots/.config/quickshell/ii/modules/common/Config.qml @@ -411,6 +411,7 @@ Singleton { property JsonObject resources: JsonObject { property int updateInterval: 3000 + property int historyLength: 60 } property JsonObject musicRecognition: JsonObject { diff --git a/dots/.config/quickshell/ii/modules/common/Persistent.qml b/dots/.config/quickshell/ii/modules/common/Persistent.qml index 5335deb9d..f40fdf6cb 100644 --- a/dots/.config/quickshell/ii/modules/common/Persistent.qml +++ b/dots/.config/quickshell/ii/modules/common/Persistent.qml @@ -93,6 +93,13 @@ Singleton { property real x: 100 property real y: 130 } + property JsonObject resources: JsonObject { + property bool pinned: false + property bool clickthrough: true + property real x: 1000 + property real y: 320 + property int tabIndex: 0 + } property JsonObject volumeMixer: JsonObject { property bool pinned: false property bool clickthrough: false diff --git a/dots/.config/quickshell/ii/modules/common/widgets/Graph.qml b/dots/.config/quickshell/ii/modules/common/widgets/Graph.qml new file mode 100644 index 000000000..4747e5665 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/common/widgets/Graph.qml @@ -0,0 +1,51 @@ +import QtQuick +import qs.modules.common +import qs.modules.common.functions + +/* + * Simple one value line graph + */ +Canvas { + id: root + + enum Alignment { Left, Right } + + required property list values + property int points: values.length + property color color: Appearance.colors.colPrimary + property real fillOpacity: 0.5 + property var alignment: Graph.Alignment.Left + + onValuesChanged: root.requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, width, height) + if (!root.values || root.values.length < 2) + return + + var n = root.points + var dx = width / (n - 1) + ctx.strokeStyle = root.color + ctx.fillStyle = ColorUtils.transparentize(root.color, 1 - root.fillOpacity) + ctx.lineWidth = 2 + ctx.beginPath() + for (var i = 0; i < n; ++i) { + var valueIndex = (root.alignment === Graph.Alignment.Right) ? root.values.length - n + i : i + if (valueIndex < 0 || valueIndex >= root.values.length) { + continue; // No data for this point + } + var x = i * dx + var norm = root.values[valueIndex] // already in 0-1 range + var y = height - norm * height + if (valueIndex === 0) { + ctx.moveTo(x, height) + ctx.lineTo(x, y) + } else { + ctx.lineTo(x, y) + } + } + ctx.stroke() + ctx.lineTo(width, height) + ctx.fill() + } +} diff --git a/dots/.config/quickshell/ii/modules/overlay/OverlayContext.qml b/dots/.config/quickshell/ii/modules/overlay/OverlayContext.qml index 22746c4de..c13b1933c 100644 --- a/dots/.config/quickshell/ii/modules/overlay/OverlayContext.qml +++ b/dots/.config/quickshell/ii/modules/overlay/OverlayContext.qml @@ -6,9 +6,10 @@ Singleton { id: root readonly property list availableWidgets: [ - { identifier: "crosshair", materialSymbol: "point_scan" }, - { identifier: "volumeMixer", materialSymbol: "volume_up" }, { identifier: "recorder", materialSymbol: "screen_record" }, + { identifier: "volumeMixer", materialSymbol: "volume_up" }, + { identifier: "crosshair", materialSymbol: "point_scan" }, + { identifier: "resources", materialSymbol: "browse_activity" } ] readonly property bool hasPinnedWidgets: root.pinnedWidgetIdentifiers.length > 0 diff --git a/dots/.config/quickshell/ii/modules/overlay/OverlayWidgetDelegateChooser.qml b/dots/.config/quickshell/ii/modules/overlay/OverlayWidgetDelegateChooser.qml index 39df62ac3..90f85a7fd 100644 --- a/dots/.config/quickshell/ii/modules/overlay/OverlayWidgetDelegateChooser.qml +++ b/dots/.config/quickshell/ii/modules/overlay/OverlayWidgetDelegateChooser.qml @@ -9,6 +9,7 @@ import Quickshell.Bluetooth import qs.modules.overlay.crosshair import qs.modules.overlay.volumeMixer import qs.modules.overlay.recorder +import qs.modules.overlay.resources DelegateChooser { id: root @@ -17,4 +18,5 @@ DelegateChooser { DelegateChoice { roleValue: "crosshair"; Crosshair {} } DelegateChoice { roleValue: "volumeMixer"; VolumeMixer {} } DelegateChoice { roleValue: "recorder"; Recorder {} } + DelegateChoice { roleValue: "resources"; Resources {} } } diff --git a/dots/.config/quickshell/ii/modules/overlay/recorder/Recorder.qml b/dots/.config/quickshell/ii/modules/overlay/recorder/Recorder.qml index d32adedea..78d747ed6 100644 --- a/dots/.config/quickshell/ii/modules/overlay/recorder/Recorder.qml +++ b/dots/.config/quickshell/ii/modules/overlay/recorder/Recorder.qml @@ -2,7 +2,6 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import Quickshell -import Quickshell.Hyprland import qs import qs.modules.common import qs.modules.common.widgets diff --git a/dots/.config/quickshell/ii/modules/overlay/resources/Resources.qml b/dots/.config/quickshell/ii/modules/overlay/resources/Resources.qml new file mode 100644 index 000000000..f5f5ff286 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/overlay/resources/Resources.qml @@ -0,0 +1,134 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland +import Qt5Compat.GraphicalEffects +import Qt.labs.synchronizer +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.overlay + +StyledOverlayWidget { + id: root + property list resources: [ + { + "icon": "planner_review", + "name": Translation.tr("CPU"), + "history": ResourceUsage.cpuUsageHistory, + "maxAvailableString": ResourceUsage.maxAvailableCpuString + }, + { + "icon": "memory", + "name": Translation.tr("RAM"), + "history": ResourceUsage.memoryUsageHistory, + "maxAvailableString": ResourceUsage.maxAvailableMemoryString + }, + { + "icon": "swap_horiz", + "name": Translation.tr("Swap"), + "history": ResourceUsage.swapUsageHistory, + "maxAvailableString": ResourceUsage.maxAvailableSwapString + }, + ] + + contentItem: Rectangle { + id: contentItem + anchors.centerIn: parent + color: Appearance.m3colors.m3surfaceContainer + property real padding: 4 + implicitWidth: 350 + implicitHeight: 200 + // implicitHeight: contentColumn.implicitHeight + padding * 2 + ColumnLayout { + id: contentColumn + anchors { + fill: parent + margins: parent.padding + } + spacing: 10 + + SecondaryTabBar { + id: tabBar + + currentIndex: Persistent.states.overlay.resources.tabIndex + onCurrentIndexChanged: { + Persistent.states.overlay.resources.tabIndex = tabBar.currentIndex; + } + + Repeater { + model: root.resources.length + delegate: SecondaryTabButton { + required property int index + property var modelData: root.resources[index] + buttonIcon: modelData.icon + buttonText: modelData.name + } + } + } + + ResourceSummary { + Layout.margins: 8 + history: root.resources[tabBar.currentIndex]?.history ?? [] + maxAvailableString: root.resources[tabBar.currentIndex]?.maxAvailableString ?? "--" + } + } + } + + component ResourceSummary: RowLayout { + id: resourceSummary + required property list history + required property string maxAvailableString + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 12 + + ColumnLayout { + spacing: 2 + StyledText { + text: (resourceSummary.history[resourceSummary.history.length - 1] * 100).toFixed(1) + "%" + font { + family: Appearance.font.family.numbers + variableAxes: Appearance.font.variableAxes.numbers + pixelSize: Appearance.font.pixelSize.huge + } + } + StyledText { + text: Translation.tr("of %1").arg(resourceSummary.maxAvailableString) + font { + // family: Appearance.font.family.numbers + // variableAxes: Appearance.font.variableAxes.numbers + pixelSize: Appearance.font.pixelSize.smallie + } + color: Appearance.colors.colSubtext + } + Item { + Layout.fillHeight: true + } + } + Rectangle { + id: graphBg + Layout.fillWidth: true + Layout.fillHeight: true + radius: Appearance.rounding.small + color: Appearance.colors.colSecondaryContainer + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: graphBg.width + height: graphBg.height + radius: graphBg.radius + } + } + Graph { + anchors.fill: parent + values: root.resources[tabBar.currentIndex]?.history ?? [] + points: ResourceUsage.historyLength + alignment: Graph.Alignment.Right + } + } + } +} diff --git a/dots/.config/quickshell/ii/services/ResourceUsage.qml b/dots/.config/quickshell/ii/services/ResourceUsage.qml index 650528408..e42022cb5 100644 --- a/dots/.config/quickshell/ii/services/ResourceUsage.qml +++ b/dots/.config/quickshell/ii/services/ResourceUsage.qml @@ -10,17 +10,55 @@ import Quickshell.Io * Simple polled resource usage service with RAM, Swap, and CPU usage. */ Singleton { - property double memoryTotal: 1 - property double memoryFree: 1 - property double memoryUsed: memoryTotal - memoryFree - property double memoryUsedPercentage: memoryUsed / memoryTotal - property double swapTotal: 1 - property double swapFree: 1 - property double swapUsed: swapTotal - swapFree - property double swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0 - property double cpuUsage: 0 + id: root + property real memoryTotal: 1 + property real memoryFree: 0 + property real memoryUsed: memoryTotal - memoryFree + property real memoryUsedPercentage: memoryUsed / memoryTotal + property real swapTotal: 1 + property real swapFree: 0 + property real swapUsed: swapTotal - swapFree + property real swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0 + property real cpuUsage: 0 property var previousCpuStats + property string maxAvailableMemoryString: kbToGbString(ResourceUsage.memoryTotal) + property string maxAvailableSwapString: kbToGbString(ResourceUsage.swapTotal) + property string maxAvailableCpuString: "--" + + readonly property int historyLength: Config?.options.resources.historyLength ?? 60 + property list cpuUsageHistory: [] + property list memoryUsageHistory: [] + property list swapUsageHistory: [] + + function kbToGbString(kb) { + return (kb / (1024 * 1024)).toFixed(1) + " GB"; + } + + function updateMemoryUsageHistory() { + memoryUsageHistory = [...memoryUsageHistory, memoryUsedPercentage] + if (memoryUsageHistory.length > historyLength) { + memoryUsageHistory.shift() + } + } + function updateSwapUsageHistory() { + swapUsageHistory = [...swapUsageHistory, swapUsedPercentage] + if (swapUsageHistory.length > historyLength) { + swapUsageHistory.shift() + } + } + function updateCpuUsageHistory() { + cpuUsageHistory = [...cpuUsageHistory, cpuUsage] + if (cpuUsageHistory.length > historyLength) { + cpuUsageHistory.shift() + } + } + function updateHistories() { + updateMemoryUsageHistory() + updateSwapUsageHistory() + updateCpuUsageHistory() + } + Timer { interval: 1 running: true @@ -29,6 +67,7 @@ Singleton { // Reload files fileMeminfo.reload() fileStat.reload() + fileCpuinfo.reload() // Parse memory and swap usage const textMeminfo = fileMeminfo.text() @@ -53,10 +92,36 @@ Singleton { previousCpuStats = { total, idle } } + + // Parse max CPU frequency + const textCpuinfo = fileCpuinfo.text() + // Try to find 'cpu max MHz', fallback to highest 'cpu MHz' + let maxMHz = 0 + let match + // Try cpu max MHz (modern kernels) + match = textCpuinfo.match(/cpu max MHz\s*:\s*([\d.]+)/) + if (match) { + maxMHz = Number(match[1]) + } else { + // Fallback: find all cpu MHz lines and take the max + let mhzRegex = /cpu MHz\s*:\s*([\d.]+)/g + let mhzMatch + let mhzList = [] + while ((mhzMatch = mhzRegex.exec(textCpuinfo)) !== null) { + mhzList.push(Number(mhzMatch[1])) + } + if (mhzList.length > 0) { + maxMHz = Math.max.apply(null, mhzList) + } + } + root.maxAvailableCpuString = maxMHz > 0 ? (maxMHz / 1000).toFixed(1) + "GHz" : "--" + + root.updateHistories() interval = Config.options?.resources?.updateInterval ?? 3000 } } FileView { id: fileMeminfo; path: "/proc/meminfo" } FileView { id: fileStat; path: "/proc/stat" } + FileView { id: fileCpuinfo; path: "/proc/cpuinfo" } }