diff --git a/Components/CircularIndicator.qml b/Components/CircularIndicator.qml index 662c571..60466e5 100644 --- a/Components/CircularIndicator.qml +++ b/Components/CircularIndicator.qml @@ -17,8 +17,8 @@ BusyIndicator { } property int animState - property color bgColour: DynamicColors.palette.m3secondaryContainer - property color fgColour: DynamicColors.palette.m3primary + property color bgColor: DynamicColors.palette.m3secondaryContainer + property color fgColor: DynamicColors.palette.m3primary property real implicitSize: Appearance.font.size.normal * 3 property real internalStrokeWidth: strokeWidth readonly property alias progress: manager.progress @@ -31,8 +31,8 @@ BusyIndicator { contentItem: CircularProgress { anchors.fill: parent - bgColour: root.bgColour - fgColour: root.fgColour + bgColor: root.bgColor + fgColor: root.fgColor padding: root.padding rotation: manager.rotation startAngle: manager.startFraction * 360 @@ -73,7 +73,6 @@ BusyIndicator { CircularIndicatorManager { id: manager - } NumberAnimation { diff --git a/Components/CircularProgress.qml b/Components/CircularProgress.qml index fa1011c..9b22cda 100644 --- a/Components/CircularProgress.qml +++ b/Components/CircularProgress.qml @@ -1,66 +1,116 @@ +pragma ComponentBehavior: Bound + import QtQuick import QtQuick.Shapes +import ZShell.Components import qs.Config -Shape { +Item { id: root - readonly property real arcRadius: (size - padding - strokeWidth) / 2 - property color bgColour: DynamicColors.palette.m3secondaryContainer - property color fgColour: DynamicColors.palette.m3primary + readonly property real arcRadius: (size - padding - strokeWidth * (1 + waveAmplitude * 2)) / 2 + property color bgColor: DynamicColors.palette.m3secondaryContainer + property real clampedVal: Math.max(1 / 360, Math.min(1, isNaN(value) ? 0 : value)) + readonly property real dotAngleRad: (startAngle + sweepAngle - gapAngle * (sweepAngle < 360 ? 0 : 1)) * Math.PI / 180 + property color fgColor: DynamicColors.palette.m3primary readonly property real gapAngle: ((spacing + strokeWidth) / (arcRadius || 1)) * (180 / Math.PI) + property alias hasEndIndicator: dot.active + property real implicitSize property int padding: 0 readonly property real size: Math.min(width, height) property int spacing: Appearance.spacing.small property int startAngle: -90 - property int strokeWidth: Appearance.padding.smaller - readonly property real vValue: value || 1 / 360 + property int strokeWidth: Appearance.padding.small + property int sweepAngle: 360 + readonly property real thickness: strokeWidth * (1 + waveAmplitude) * 2 property real value + property alias waveAmplitude: wave.amplitudeMultiplier + property alias waveDuration: waveProgAnim.duration + property alias waveFrequency: wave.frequency + property bool wavePaused + property bool wavy: false - asynchronous: true - preferredRendererType: Shape.CurveRenderer + implicitHeight: implicitSize + implicitWidth: implicitSize - ShapePath { - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: root.bgColour - strokeWidth: root.strokeWidth + Shape { + asynchronous: true + opacity: Math.min(1, remainingArc.sweepAngle) + preferredRendererType: Shape.CurveRenderer - Behavior on strokeColor { - CAnim { - duration: Appearance.anim.durations.large + ShapePath { + capStyle: ShapePath.RoundCap + fillColor: "transparent" + strokeColor: root.bgColor + strokeWidth: Math.min(1, remainingArc.sweepAngle) * root.strokeWidth + + Behavior on strokeColor { + CAnim { + } } - } - PathAngleArc { - centerX: root.size / 2 - centerY: root.size / 2 - radiusX: root.arcRadius - radiusY: root.arcRadius - startAngle: root.startAngle + 360 * root.vValue + root.gapAngle - sweepAngle: Math.max(-root.gapAngle, 360 * (1 - root.vValue) - root.gapAngle * 2) + PathAngleArc { + id: remainingArc + + centerX: root.size / 2 + centerY: root.size / 2 + radiusX: root.arcRadius + radiusY: root.arcRadius + startAngle: root.startAngle + root.clampedVal * root.sweepAngle + root.gapAngle + sweepAngle: Math.max(1 / 360, root.sweepAngle * (1 - root.clampedVal) - root.gapAngle * (root.sweepAngle < 360 ? 1 : 2)) + } } } - ShapePath { - capStyle: Appearance.rounding.scale === 0 ? ShapePath.SquareCap : ShapePath.RoundCap - fillColor: "transparent" - strokeColor: root.fgColour - strokeWidth: root.strokeWidth + WavyLine { + id: wave - Behavior on strokeColor { - CAnim { - duration: Appearance.anim.durations.large + amplitudeMultiplier: root.wavy ? 0.5 : 0 + anchors.fill: parent + anchors.margins: -lineWidth * amplitudeMultiplier + color: root.fgColor + frequency: 8 + fullAngle: root.sweepAngle + lineWidth: root.strokeWidth + pathType: WavyLine.Arc + radius: root.arcRadius + startAngle: root.startAngle + value: root.clampedVal + + Behavior on amplitudeMultiplier { + Anim { + type: Anim.DefaultEffects } } + Behavior on color { + CAnim { + } + } + Anim on waveProgress { + id: waveProgAnim - PathAngleArc { - centerX: root.size / 2 - centerY: root.size / 2 - radiusX: root.arcRadius - radiusY: root.arcRadius - startAngle: root.startAngle - sweepAngle: 360 * root.vValue + duration: 2000 + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + paused: root.wavePaused || wave.amplitudeMultiplier === 0 + running: true + to: 1 + } + } + + Loader { + id: dot + + x: root.size / 2 + root.arcRadius * Math.cos(root.dotAngleRad) - width / 2 + y: root.size / 2 + root.arcRadius * Math.sin(root.dotAngleRad) - height / 2 + + sourceComponent: CustomRect { + color: root.fgColor + implicitHeight: Math.min(1, remainingArc.sweepAngle) * Math.min(4, root.strokeWidth) + implicitWidth: Math.min(1, remainingArc.sweepAngle) * Math.min(4, root.strokeWidth) + opacity: Math.min(1, remainingArc.sweepAngle) + radius: Appearance.rounding.full } } } diff --git a/Components/CustomProgressBar.qml b/Components/CustomProgressBar.qml new file mode 100644 index 0000000..b0e7a4f --- /dev/null +++ b/Components/CustomProgressBar.qml @@ -0,0 +1,237 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Templates +import ZShell +import ZShell.Components +import ZShell.Internal +import qs.Config + +ProgressBar { + id: root + + enum IndeterminateAnimState { + Running, + Completing, + Stopped + } + + property color bgColor: DynamicColors.palette.m3secondaryContainer + property color fgColor: DynamicColors.palette.m3primary + property int indeterminateAnimState: CustomProgressBar.Stopped + property real waveAmplitude: 0.5 + property int waveDuration: 1000 + property int waveFrequency: 6 + property bool wavePaused + property bool wavy + + function toBounds(startFrac: real, endFrac: real, gapSize: real): point { + startFrac = ZUtils.clamp(startFrac, 0, 1); + endFrac = ZUtils.clamp(endFrac, 0, 1); + + // Ramp down gap size + const GAP_RAMP_DOWN_THRESHOLD = 0.01; + gapSize += height / 2; + const startGapSize = (gapSize * ZUtils.clamp(startFrac, 0, GAP_RAMP_DOWN_THRESHOLD) / GAP_RAMP_DOWN_THRESHOLD); + const endGapSize = (gapSize * (1 - ZUtils.clamp(endFrac, 1 - GAP_RAMP_DOWN_THRESHOLD, 1)) / GAP_RAMP_DOWN_THRESHOLD); + const start = width * startFrac + startGapSize; + const end = width * endFrac - endGapSize; + + return start >= end ? Qt.point(0, 0) : Qt.point(start, end); + } + + function updateIAnimState(): void { + if (indeterminate) { + manager.completeEndProgress = 0; + indeterminateAnimState = CustomProgressBar.Running; + } else if (indeterminateAnimState === CustomProgressBar.Running) { + indeterminateAnimState = CustomProgressBar.Completing; + } + } + + implicitHeight: 4 + implicitWidth: 200 + + contentItem: Loader { + anchors.fill: parent + asynchronous: true + sourceComponent: root.indeterminate || root.indeterminateAnimState !== CustomProgressBar.Stopped ? indeterminateComp : determinateComp + } + + Component.onCompleted: updateIAnimState() + onIndeterminateChanged: updateIAnimState() + + LinearIndicatorManager { + id: manager + + gap: Appearance.spacing.extraSmall + + Anim on completeEndProgress { + duration: manager.completeEndDuration + running: root.indeterminateAnimState === CustomProgressBar.Completing + to: 1 + + onFinished: { + if (root.indeterminateAnimState === CustomProgressBar.Completing) + root.indeterminateAnimState = CustomProgressBar.Stopped; + } + } + Anim on progress { + duration: manager.duration + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + running: root.indeterminateAnimState !== CustomProgressBar.Stopped + to: 1 + } + } + + Component { + id: determinateComp + + Item { + Line { + id: remaining + + anchors.right: parent.right + implicitWidth: parent.width - wave.implicitWidth - Appearance.spacing.extraSmall + } + + Line { + property real implicitSize + + anchors.right: parent.right + anchors.rightMargin: (parent.height - implicitHeight) / 2 + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + implicitHeight: implicitSize + implicitWidth: implicitSize + radius: Appearance.rounding.full + + Behavior on implicitSize { + Anim { + type: Anim.FastSpatial + } + } + + Component.onCompleted: implicitSize = Qt.binding(() => parent.width - wave.implicitWidth < parent.height ? parent.height : 4) + } + + Wave { + id: wave + + anchors.left: parent.left + + Behavior on implicitWidth { + Anim { + } + } + + Component.onCompleted: implicitWidth = Qt.binding(() => parent.width * root.visualPosition) + } + } + } + + Component { + id: indeterminateComp + + Item { + id: content + + Line { + bounds: { + const i = manager.activeIndicators[0]; // qmllint disable unresolved-type + return i ? root.toBounds(0, i.startFraction, i.gapSize / 2) : Qt.point(0, 0); + } + } + + Line { + bounds: { + const i = manager.activeIndicators[manager.activeIndicators.length - 1]; // qmllint disable unresolved-type + return i ? root.toBounds(i.endFraction, 1, i.gapSize / 2) : Qt.point(0, 0); + } + } + + Instantiator { + model: Math.max(manager.activeIndicators.length, 1) - 1 // qmllint disable unresolved-type + + delegate: Line { + readonly property LinearIndicatorSegment cur: manager.activeIndicators[index] // qmllint disable unresolved-type + required property int index + readonly property LinearIndicatorSegment next: manager.activeIndicators[index + 1 % manager.activeIndicators.length] // qmllint disable unresolved-type + + bounds: root.toBounds(cur.endFraction, next.startFraction, cur.gapSize / 2) + } + + onObjectAdded: (_, obj) => content.data.push(obj) + onObjectRemoved: (_, obj) => { + const idx = content.data.indexOf(obj); + if (idx !== -1) + content.data.splice(idx, 1); + } + } + + Instantiator { + model: manager.activeIndicators // qmllint disable unresolved-type + + delegate: Wave { + readonly property point bounds: root.toBounds(modelData.startFraction, modelData.endFraction, modelData.gapSize / 2) + required property LinearIndicatorSegment modelData + + color: root.fgColor + implicitWidth: bounds.y - bounds.x + x: bounds.x + } + + onObjectAdded: (_, obj) => content.data.push(obj) + onObjectRemoved: (_, obj) => { + const idx = content.data.indexOf(obj); + if (idx !== -1) + content.data.splice(idx, 1); + } + } + } + } + + component Line: CustomRect { + property point bounds + + anchors.verticalCenter: parent.verticalCenter + color: root.bgColor + implicitHeight: parent.height + implicitWidth: bounds.y - bounds.x + radius: Appearance.rounding.full + x: bounds.x + } + component Wave: WavyLine { + id: wave + + amplitudeMultiplier: root.wavy ? root.waveAmplitude : 0 + anchors.verticalCenter: parent.verticalCenter + color: root.fgColor + frequency: root.waveFrequency + fullLength: parent.width + implicitHeight: lineWidth * amplitudeMultiplier * 2 + lineWidth + lineWidth: parent.height + startX: x + + Behavior on amplitudeMultiplier { + Anim { + type: Anim.DefaultEffects + } + } + Behavior on color { + CAnim { + } + } + Anim on waveProgress { + duration: root.waveDuration + easing.type: Easing.Linear + from: 0 + loops: Animation.Infinite + paused: wave.amplitudeMultiplier === 0 || root.wavePaused + running: true + to: 1 + } + } +} diff --git a/Config/AppearanceConf.qml b/Config/AppearanceConf.qml index 859e0a3..e2f3d07 100644 --- a/Config/AppearanceConf.qml +++ b/Config/AppearanceConf.qml @@ -77,9 +77,11 @@ JsonObject { } } component Padding: JsonObject { + property int extraLarge: 28 * scale property int extraLargeIncreased: 32 * scale property int extraSmall: 4 * scale property int large: 16 * scale + property int largeIncreased: 20 * scale property int larger: 12 * scale property int normal: 8 * scale property real scale: 1 diff --git a/Drawers/Windows.qml b/Drawers/Windows.qml index e0ffa69..a0a0b06 100644 --- a/Drawers/Windows.qml +++ b/Drawers/Windows.qml @@ -271,7 +271,7 @@ CustomWindow { implicitHeight: panels.resources.height implicitWidth: panels.resources.width panel: panels.resourcesWrapper - radius: Appearance.rounding.normal + radius: Appearance.rounding.large x: panels.resourcesWrapper.x + panels.resources.x + Config.barConfig.border y: panels.resourcesWrapper.y + panels.resources.y + bar.implicitHeight } diff --git a/Helpers/SystemUsage.qml b/Helpers/SystemUsage.qml index da9ab18..7e8881e 100644 --- a/Helpers/SystemUsage.qml +++ b/Helpers/SystemUsage.qml @@ -9,33 +9,13 @@ 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 - property real lastCpuIdle - property real lastCpuTotal - readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0 - property real memTotal - property real memUsed property int refCount - 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(); @@ -78,8 +58,6 @@ Singleton { triggeredOnStart: true onTriggered: { - stat.reload(); - meminfo.reload(); if (root.gpuType === "GENERIC") gpuUsage.running = true; @@ -88,170 +66,6 @@ Singleton { } } - Timer { - interval: 60000 * 120 - repeat: true - running: true - triggeredOnStart: true - - onTriggered: { - storage.running = true; - } - } - - Timer { - interval: Config.dashboard.resourceUpdateInterval * 5 - repeat: true - running: root.refCount > 0 - triggeredOnStart: true - - onTriggered: { - sensors.running = true; - } - } - - 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 - - path: "/proc/stat" - - onLoaded: { - const data = text().match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/); - if (data) { - const stats = data.slice(1).map(n => parseInt(n, 10)); - const total = stats.reduce((a, b) => a + b, 0); - const idle = stats[3] + (stats[4] ?? 0); - - const totalDiff = total - root.lastCpuTotal; - const idleDiff = idle - root.lastCpuIdle; - const newCpuPerc = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0; - - root.lastCpuTotal = total; - root.lastCpuIdle = idle; - - if (Math.abs(newCpuPerc - root.cpuPerc) >= 0.01) - root.cpuPerc = newCpuPerc; - } - } - } - - FileView { - id: meminfo - - path: "/proc/meminfo" - - onLoaded: { - const data = text(); - const total = parseInt(data.match(/MemTotal: *(\d+)/)[1], 10) || 1; - const used = (root.memTotal - parseInt(data.match(/MemAvailable: *(\d+)/)[1], 10)) || 0; - - if (root.memTotal !== total) - root.memTotal = total; - - if (Math.abs(used - root.memUsed) >= 16384) - root.memUsed = used; - } - } - - Process { - id: storage - - command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"] - - stdout: StdioCollector { - onStreamFinished: { - const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal } - const lines = 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="([^"]*)"/); - - if (!nameMatch || !typeMatch) - continue; - - 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 totalSize = 0; - - 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.disks = diskList; - } - } - } - Process { id: gpuNameDetect @@ -412,53 +226,4 @@ Singleton { } } } - - Process { - id: sensors - - command: ["sensors"] - environment: ({ - LANG: "C.UTF-8", - LC_ALL: "C.UTF-8" - }) - - stdout: StdioCollector { - onStreamFinished: { - let cpuTemp = text.match(/(?:Package id [0-9]+|Tdie):\s+((\+|-)[0-9.]+)(°| )C/); - if (!cpuTemp) - // If AMD Tdie pattern failed, try fallback on Tctl - cpuTemp = text.match(/Tctl:\s+((\+|-)[0-9.]+)(°| )C/); - - if (cpuTemp && Math.abs(parseFloat(cpuTemp[1]) - root.cpuTemp) >= 0.5) - root.cpuTemp = parseFloat(cpuTemp[1]); - - if (root.gpuType !== "GENERIC") - return; - - let eligible = false; - let sum = 0; - let count = 0; - - for (const line of text.trim().split("\n")) { - if (line === "Adapter: PCI adapter") - eligible = true; - else if (line === "") - eligible = false; - else if (eligible) { - let match = line.match(/^(temp[0-9]+|GPU core|edge)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); - if (!match) - // Fall back to junction/mem if GPU doesn't have edge temp (for AMD GPUs) - match = line.match(/^(junction|mem)+:\s+\+([0-9]+\.[0-9]+)(°| )C/); - - if (match) { - sum += parseFloat(match[2]); - count++; - } - } - } - - root.gpuTemp = count > 0 ? sum / count : 0; - } - } - } } diff --git a/Modules/Dashboard/Dash/Resources.qml b/Modules/Dashboard/Dash/Resources.qml index cc34a52..c76fd6e 100644 --- a/Modules/Dashboard/Dash/Resources.qml +++ b/Modules/Dashboard/Dash/Resources.qml @@ -1,92 +1,89 @@ import QtQuick +import QtQuick.Layouts +import ZShell.Services import qs.Components import qs.Helpers import qs.Config -Row { +Item { id: root anchors.bottom: parent.bottom anchors.top: parent.top - padding: Appearance.padding.large - spacing: Appearance.spacing.large + implicitWidth: layout.implicitWidth + layout.anchors.margins * 4 + + ServiceRef { + service: Storage + } + + ServiceRef { + service: Memory + } + + ServiceRef { + service: Cpu + } Ref { service: SystemUsage } - Resource { - color: DynamicColors.palette.m3primary - icon: "memory" - value: SystemUsage.cpuPerc - } - - Resource { - color: DynamicColors.palette.m3secondary - icon: "memory_alt" - value: SystemUsage.memPerc - } - - Resource { - color: DynamicColors.palette.m3tertiary - icon: "gamepad" - value: SystemUsage.gpuPerc - } - - Resource { - color: DynamicColors.palette.m3primary - icon: "host" - value: SystemUsage.gpuMemUsed - } - - Resource { - color: DynamicColors.palette.m3secondary - icon: "hard_disk" - value: SystemUsage.storagePerc - } - - component Resource: Item { - id: res - - required property color color - required property string icon - required property real value + ColumnLayout { + id: layout anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter anchors.margins: Appearance.padding.large anchors.top: parent.top - implicitWidth: icon.implicitWidth + spacing: Appearance.spacing.normal - Behavior on value { - Anim { - duration: Appearance.anim.durations.large - } + Resource { + fgColor: DynamicColors.palette.m3primary + icon: "memory" + value: Cpu.percentage } - CustomRect { - anchors.bottom: icon.top - anchors.bottomMargin: Appearance.spacing.small - anchors.horizontalCenter: parent.horizontalCenter - anchors.top: parent.top - color: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - implicitWidth: Config.dashboard.sizes.resourceProgessThickness - radius: Appearance.rounding.full + Resource { + fgColor: DynamicColors.palette.m3secondary + icon: "memory_alt" + value: Memory.percentage + } - CustomRect { - anchors.bottom: parent.bottom - anchors.left: parent.left - anchors.right: parent.right - color: res.color - implicitHeight: res.value * parent.height - radius: Appearance.rounding.full + Resource { + fgColor: DynamicColors.palette.m3tertiary + icon: "gamepad" + value: SystemUsage.gpuPerc + } + + Resource { + fgColor: DynamicColors.palette.m3primary + icon: "host" + value: SystemUsage.gpuMemUsed + } + + Resource { + fgColor: DynamicColors.palette.m3secondary + icon: "hard_disk" + value: Storage.percentage + } + } + + component Resource: CircularProgress { + id: res + + required property string icon + + Layout.fillHeight: true + implicitSize: height + + Behavior on clampedVal { + Anim { } } MaterialIcon { - id: icon - - anchors.bottom: parent.bottom - color: res.color + anchors.centerIn: parent + color: res.fgColor text: res.icon } } diff --git a/Modules/Lock/Resources.qml b/Modules/Lock/Resources.qml index 060c717..01c1f23 100644 --- a/Modules/Lock/Resources.qml +++ b/Modules/Lock/Resources.qml @@ -23,7 +23,7 @@ GridLayout { Resource { Layout.bottomMargin: Appearance.padding.large Layout.topMargin: Appearance.padding.large - colour: DynamicColors.palette.m3primary + color: DynamicColors.palette.m3primary icon: "memory" value: SystemUsage.cpuPerc } @@ -31,7 +31,7 @@ GridLayout { Resource { Layout.bottomMargin: Appearance.padding.large Layout.topMargin: Appearance.padding.large - colour: DynamicColors.palette.m3secondary + color: DynamicColors.palette.m3secondary icon: "memory_alt" value: SystemUsage.memPerc } @@ -39,7 +39,7 @@ GridLayout { component Resource: CustomRect { id: res - required property color colour + required property color color required property string icon required property real value @@ -58,8 +58,8 @@ GridLayout { id: circ anchors.fill: parent - bgColour: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) - fgColour: res.colour + bgColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHighest, 3) + fgColor: res.color padding: Appearance.padding.large * 3 strokeWidth: width < 200 ? Appearance.padding.smaller : Appearance.padding.normal value: res.value @@ -69,7 +69,7 @@ GridLayout { id: icon anchors.centerIn: parent - color: res.colour + color: res.color font.pointSize: (circ.arcRadius * 0.7) || 1 font.weight: 600 text: res.icon diff --git a/Modules/Resources.qml b/Modules/Resources.qml index a9cccb1..b0194d0 100644 --- a/Modules/Resources.qml +++ b/Modules/Resources.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import QtQuick.Layouts +import ZShell.Services import qs.Helpers import qs.Modules import qs.Config @@ -16,7 +17,7 @@ CustomRect { clip: true color: visibilities.resources ? DynamicColors.palette.m3primary : DynamicColors.tPalette.m3surfaceContainer implicitHeight: Config.barConfig.height + Appearance.padding.smallest * 2 - implicitWidth: rowLayout.implicitWidth + Appearance.padding.normal * 2 + implicitWidth: rowLayout.implicitWidth + Appearance.padding.larger * 2 radius: Appearance.rounding.full StateLayer { @@ -29,6 +30,7 @@ CustomRect { id: rowLayout anchors.centerIn: parent + anchors.horizontalCenterOffset: -2 implicitHeight: root.implicitHeight spacing: Appearance.spacing.smaller @@ -36,13 +38,21 @@ CustomRect { service: SystemUsage } + ServiceRef { + service: Cpu + } + + ServiceRef { + service: Memory + } + Resource { Layout.alignment: Qt.AlignVCenter Layout.fillHeight: true icon: "memory" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3primary - percentage: SystemUsage.cpuPerc + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary + percentage: Cpu.percentage warningThreshold: 95 } @@ -50,8 +60,8 @@ CustomRect { Layout.fillHeight: true icon: "memory_alt" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3secondaryContainer : DynamicColors.palette.m3secondary - percentage: SystemUsage.memPerc + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3secondary + percentage: Memory.percentage warningThreshold: 80 } @@ -59,7 +69,7 @@ CustomRect { Layout.fillHeight: true icon: "gamepad" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3tertiaryContainer : DynamicColors.palette.m3tertiary + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3tertiary percentage: SystemUsage.gpuPerc } @@ -67,7 +77,7 @@ CustomRect { Layout.fillHeight: true icon: "developer_board" iconColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3onSurface - mainColor: root.visibilities.resources ? DynamicColors.palette.m3primaryContainer : DynamicColors.palette.m3primary + mainColor: root.visibilities.resources ? DynamicColors.palette.m3onPrimary : DynamicColors.palette.m3primary percentage: SystemUsage.gpuMemUsed } } diff --git a/Modules/Resources/Cards/BatteryTank.qml b/Modules/Resources/Cards/BatteryTank.qml new file mode 100644 index 0000000..0e97349 --- /dev/null +++ b/Modules/Resources/Cards/BatteryTank.qml @@ -0,0 +1,134 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.UPower +import qs.Components +import qs.Config + +CustomClippingRect { + id: root + + property real animPerc: UPower.displayDevice.percentage + + color: DynamicColors.palette.m3secondaryContainer + implicitWidth: 120 + radius: Appearance.rounding.large + + Behavior on animPerc { + Anim { + } + } + + Contents { + id: layout + + accentColor: DynamicColors.palette.m3primary + anchors.fill: parent + anchors.margins: Appearance.padding.larger + subTextColor: DynamicColors.palette.m3onSurfaceVariant + textColor: DynamicColors.palette.m3onSurface + } + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + clip: true + color: DynamicColors.palette.m3secondary + implicitHeight: parent.height * root.animPerc + radius: Appearance.rounding.extraSmall + + Contents { + accentColor: DynamicColors.palette.m3primaryContainer + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: layout.anchors.margins + anchors.right: parent.right + height: layout.height + subTextColor: DynamicColors.palette.m3secondaryContainer + textColor: DynamicColors.palette.m3onSecondary + } + } + + component Contents: ColumnLayout { + id: contents + + required property color accentColor + readonly property bool charging: [UPowerDeviceState.Charging, UPowerDeviceState.FullyCharged, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state) + required property color subTextColor + required property color textColor + + spacing: 0 + + MaterialIcon { + Layout.leftMargin: -Appearance.padding.extraSmall + color: contents.accentColor + text: "battery_full" + } + + CustomText { + Layout.fillWidth: true + color: contents.textColor + text: qsTr("Battery") + } + + Item { + Layout.fillHeight: true + } + + CustomText { + Layout.alignment: Qt.AlignRight + animate: true + color: contents.subTextColor + text: { + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return qsTr("Full"); + + if (contents.charging) + 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`; + } + } + + RowLayout { + Layout.alignment: Qt.AlignRight + Layout.bottomMargin: -Appearance.padding.small + Layout.rightMargin: -Appearance.padding.extraSmall + Layout.topMargin: -Appearance.padding.extraSmall + spacing: Appearance.spacing.extraSmall + + MaterialIcon { + color: contents.accentColor + fill: 1 + opacity: contents.charging ? 1 : 0 + scale: contents.charging ? 1 : 0 + text: "bolt" + + Behavior on opacity { + Anim { + type: Anim.FastEffects + } + } + Behavior on scale { + Anim { + type: Anim.FastSpatial + } + } + } + + CustomText { + color: contents.accentColor + text: `${Math.round(UPower.displayDevice.percentage * 100)}%` + } + } + } +} diff --git a/Modules/Resources/Cards/HeroCard.qml b/Modules/Resources/Cards/HeroCard.qml new file mode 100644 index 0000000..efb23fa --- /dev/null +++ b/Modules/Resources/Cards/HeroCard.qml @@ -0,0 +1,138 @@ +import QtQuick +import QtQuick.Layouts +import qs.Components +import qs.Config + +CustomClippingRect { + id: root + + required property color accent + required property string icon + required property string label + required property string subLabel + required property real temperature + required property real usage + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: Math.max(tempProg.implicitHeight + detailsRow.implicitHeight + Appearance.spacing.large, usageColumn.implicitHeight + usageLabel.implicitHeight) + Appearance.padding.large * 2 + implicitWidth: 450 + radius: Appearance.rounding.large - Appearance.padding.normal + + CustomRect { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.top: parent.top + color: Qt.alpha(root.accent, 0.05) + implicitWidth: parent.width * root.usage + + Behavior on implicitWidth { + Anim { + } + } + } + + CircularProgress { + id: tempProg + + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.top: parent.top + fgColor: root.accent + implicitSize: Math.max(icon.implicitWidth, icon.implicitHeight) + Appearance.padding.larger * 2 + spacing: Appearance.spacing.extraSmall + strokeWidth: Appearance.padding.extraSmall + value: root.usage + + Behavior on clampedVal { + Anim { + } + } + + MaterialIcon { + id: icon + + anchors.centerIn: parent + color: root.accent + text: root.icon + } + } + + ColumnLayout { + anchors.left: tempProg.right + anchors.margins: Appearance.spacing.large + anchors.right: usageColumn.left + anchors.verticalCenter: tempProg.verticalCenter + spacing: Appearance.spacing.extraSmall + + CustomText { + color: root.accent + text: root.label + } + + CustomText { + Layout.fillWidth: true + color: DynamicColors.palette.m3onSurfaceVariant + elide: Text.ElideRight + text: root.subLabel + } + } + + ColumnLayout { + id: detailsRow + + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.margins: Appearance.padding.largeIncreased + spacing: Appearance.spacing.extraSmall + + RowLayout { + Layout.leftMargin: -Appearance.padding.extraSmall + spacing: Appearance.spacing.extraSmall + + MaterialIcon { + Layout.topMargin: Math.round(fontInfo.pointSize * 0.08) + color: root.temperature > 90 ? DynamicColors.palette.m3error : root.accent + fill: 1 + text: root.temperature > 90 ? "thermometer_alert" : "thermometer" + } + + CustomText { + text: `${Math.ceil(root.temperature)}°${"C"}` + } + } + + CustomProgressBar { + fgColor: root.accent + implicitHeight: Appearance.padding.small + indeterminate: isNaN(root.usage) || isNaN(root.temperature) + value: root.temperature / 100 + } + } + + Column { + id: usageColumn + + anchors.margins: Appearance.padding.large + anchors.right: parent.right + anchors.rightMargin: 32 + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + CustomText { + id: usageLabel + + anchors.right: parent.right + color: DynamicColors.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + text: qsTr("Usage") + } + + CustomText { + anchors.right: parent.right + color: root.accent + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + text: isNaN(root.usage) ? "...%" : Math.round(root.usage * 100) + "%" + } + } +} diff --git a/Modules/Resources/Cards/MemoryCard.qml b/Modules/Resources/Cards/MemoryCard.qml new file mode 100644 index 0000000..8000090 --- /dev/null +++ b/Modules/Resources/Cards/MemoryCard.qml @@ -0,0 +1,85 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Services +import qs.Components +import qs.Config + +CustomRect { + id: root + + readonly property color accent: DynamicColors.palette.m3tertiary + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + implicitWidth: layout.implicitWidth + Appearance.padding.extraLargeIncreased * 2 + radius: Appearance.rounding.medium + + ServiceRef { + service: Memory + } + + ColumnLayout { + id: layout + + anchors.centerIn: parent + spacing: Appearance.spacing.extraSmall + + RowLayout { + Layout.leftMargin: -Appearance.padding.extraSmall + spacing: Appearance.spacing.small + + MaterialIcon { + color: root.accent + fill: 1 + text: "memory_alt" + } + + CustomText { + text: qsTr("Memory") + } + } + + CircularProgress { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Appearance.spacing.large + fgColor: root.accent + implicitSize: usageColumn.implicitHeight + thickness + Appearance.padding.largeIncreased * 2 + startAngle: -225 + sweepAngle: 270 + value: Memory.percentage + + Behavior on clampedVal { + Anim { + } + } + + ColumnLayout { + id: usageColumn + + anchors.centerIn: parent + anchors.verticalCenterOffset: Appearance.padding.extraSmall + spacing: 0 + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: root.accent + text: Math.round(Memory.percentage * 100) + "%" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Used") + } + } + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + text: { + const fmt = UsageFmt.formatKib(Memory.used, Memory.total); + return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; + } + } + } +} diff --git a/Modules/Resources/Cards/NetworkCard.qml b/Modules/Resources/Cards/NetworkCard.qml new file mode 100644 index 0000000..7f3f052 --- /dev/null +++ b/Modules/Resources/Cards/NetworkCard.qml @@ -0,0 +1,183 @@ +import QtQuick +import QtQuick.Layouts +import ZShell.Internal +import qs.Helpers +import qs.Components +import qs.Config + +CustomRect { + id: root + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: 220 + implicitWidth: 300 + radius: Appearance.rounding.large - Appearance.padding.normal + + Ref { + service: NetworkUsage + } + + ColumnLayout { + id: layout + + anchors.bottomMargin: Appearance.padding.larger + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: 0 + + RowLayout { + spacing: Appearance.spacing.small + + MaterialIcon { + color: DynamicColors.palette.m3primary + text: "swap_vert" + } + + CustomText { + text: qsTr("Network") + } + } + + Item { + Layout.bottomMargin: Appearance.spacing.small + Layout.fillHeight: true + Layout.fillWidth: true + Layout.topMargin: Appearance.spacing.larger + + SparklineItem { + id: sparkline + + property real smoothMax: targetMax + property real targetMax: 1024 + + anchors.fill: parent + historyLength: NetworkUsage.historyLength + line1: NetworkUsage.uploadBuffer // qmllint disable missing-type + line1Color: DynamicColors.palette.m3secondary + line1FillAlpha: 0.15 + line2: NetworkUsage.downloadBuffer // qmllint disable missing-type + line2Color: DynamicColors.palette.m3tertiary + line2FillAlpha: 0.2 + maxValue: smoothMax + + Behavior on smoothMax { + Anim { + } + } + + Connections { + function onValuesChanged(): void { + sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); + slideAnim.restart(); + } + + target: NetworkUsage.downloadBuffer + } + + NumberAnimation { + id: slideAnim + + duration: Config.dashboard.resourceUpdateInterval + easing.type: Easing.Linear + from: 0 + property: "slideProgress" + target: sparkline + to: 1 + } + } + + // "Collecting data" placeholder + CustomText { + anchors.centerIn: parent + color: DynamicColors.palette.m3outline + text: qsTr("Collecting data...") + visible: NetworkUsage.downloadBuffer.count < 2 + } + } + + // Download row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + color: DynamicColors.palette.m3tertiary + text: "download" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Download") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3tertiary + 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.small + + MaterialIcon { + color: DynamicColors.palette.m3secondary + text: "upload" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Upload") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3secondary + 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.small + + MaterialIcon { + color: DynamicColors.palette.m3onSurfaceVariant + text: "history" + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Total") + } + + Item { + Layout.fillWidth: true + } + + CustomText { + color: DynamicColors.palette.m3onSurfaceVariant + 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"; + } + } + } + } +} diff --git a/Modules/Resources/Cards/StorageCard.qml b/Modules/Resources/Cards/StorageCard.qml new file mode 100644 index 0000000..a589fb4 --- /dev/null +++ b/Modules/Resources/Cards/StorageCard.qml @@ -0,0 +1,127 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import ZShell.Services +import qs.Components +import qs.Config + +CustomRect { + id: root + + readonly property color accent: DynamicColors.palette.m3secondary + readonly property real percentage: Storage.primaryDisk?.perc ?? 0 + + color: DynamicColors.tPalette.m3surfaceContainer + implicitHeight: layout.implicitHeight + Appearance.padding.large * 2 + implicitWidth: layout.implicitWidth + layout.anchors.margins * 2 + radius: Appearance.rounding.large - Appearance.padding.normal + + ServiceRef { + service: Storage + } + + ColumnLayout { + id: layout + + anchors.left: parent.left + anchors.margins: Appearance.padding.small + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + RowLayout { + id: row + + Layout.alignment: Qt.AlignHCenter + spacing: Appearance.spacing.large + + CircularProgress { + fgColor: root.accent + implicitSize: usageColumn.implicitHeight + thickness + Appearance.padding.large * 2 + startAngle: -225 + sweepAngle: 270 + value: root.percentage + + Behavior on clampedVal { + Anim { + } + } + + ColumnLayout { + id: usageColumn + + anchors.centerIn: parent + spacing: 0 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + color: root.accent + font.pointSize: Appearance.font.size.medium + text: "hard_drive" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: root.accent + text: Math.round(root.percentage * 100) + "%" + } + + CustomText { + Layout.alignment: Qt.AlignHCenter + color: DynamicColors.palette.m3onSurfaceVariant + text: qsTr("Used") + } + } + } + + ColumnLayout { + spacing: Appearance.spacing.extraSmall + + CustomText { + text: qsTr("Storage") + } + + CustomText { + color: root.accent + text: { + if (!Storage.primaryDisk) + return qsTr("No disks detected"); + + const fmt = UsageFmt.formatKib(Storage.primaryDisk.used, Storage.primaryDisk.total); + return `${fmt.value.toFixed(1)} / ${Math.floor(fmt.total)} ${fmt.unit}`; + } + } + } + } + + CustomSplitButton { + Layout.alignment: Qt.AlignHCenter + active: menuItems.find(m => m.modelData === Storage.primaryDisk) ?? menuItems[0] ?? null + enabled: Storage.disks.length + fallbackIcon: "storage" + fallbackText: qsTr("No disks") + menuItems: disks.instances + minLeftWidth: row.implicitWidth * 0.6 + type: CustomSplitButton.Tonal + + menu.onItemSelected: item => Storage.manualPrimaryDisk = (item as DiskItem).modelData + + Variants { + id: disks + + model: Storage.disks + + DiskItem { + } + } + } + } + + component DiskItem: MenuItem { + required property var modelData + + activeIcon: "storage" + icon: modelData === Storage.primaryDisk ? "check" : "" + text: modelData.mount + } +} diff --git a/Modules/Resources/Content.qml b/Modules/Resources/Content.qml index 78b3d89..f00bc69 100644 --- a/Modules/Resources/Content.qml +++ b/Modules/Resources/Content.qml @@ -1,792 +1,120 @@ -import Quickshell import QtQuick -import QtQuick.Controls import QtQuick.Layouts -import Quickshell.Services.UPower -import ZShell.Internal -import qs.Components +import ZShell.Services +import qs.Modules.Resources.Cards import qs.Helpers +import qs.Components import qs.Config Item { id: root - readonly property int minWidth: 400 + 400 + Appearance.spacing.normal + 120 + Appearance.padding.large * 2 - readonly property real nonAnimHeight: 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 + implicitHeight: content.implicitHeight + Appearance.padding.normal * 2 + implicitWidth: content.implicitWidth RowLayout { id: content anchors.left: parent.left - anchors.leftMargin: root.padding + anchors.margins: Appearance.padding.normal anchors.right: parent.right - anchors.rightMargin: root.padding anchors.verticalCenter: parent.verticalCenter - spacing: Appearance.spacing.normal - - Ref { - service: SystemUsage - } + spacing: Appearance.spacing.larger ColumnLayout { id: mainColumn Layout.fillWidth: true - spacing: Appearance.spacing.normal + spacing: Appearance.spacing.larger RowLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.normal - visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") + spacing: Appearance.spacing.larger + visible: cpuCard.active || gpuCard.active - 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 - } + WrappedLoader { + id: cpuCard - 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" - } - } + active: Config.dashboard.performance.showCpu - RowLayout { - Layout.fillWidth: true - spacing: Appearance.spacing.normal - visible: Config.dashboard.performance.showMemory || Config.dashboard.performance.showStorage || Config.dashboard.performance.showNetwork + sourceComponent: HeroCard { + accent: DynamicColors.palette.m3primary + icon: "memory" + label: qsTr("CPU") + subLabel: Cpu.name + temperature: Cpu.temperature + usage: Cpu.percentage - 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 - Appearance.padding.normal - - 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"; + ServiceRef { + service: Cpu } - 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") + WrappedLoader { + id: gpuCard + + active: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "" + + sourceComponent: HeroCard { + accent: DynamicColors.palette.m3secondary + icon: "desktop_windows" + label: qsTr("GPU") + subLabel: SystemUsage.gpuName + temperature: SystemUsage.gpuTemp + usage: SystemUsage.gpuPerc + + Ref { + service: SystemUsage + } + } } } - Item { - Layout.fillHeight: true - } + RowLayout { + spacing: Appearance.spacing.larger + visible: storageCard.active || networkCard.active || memoryCard.active - // Bottom Info Section - ColumnLayout { - Layout.fillWidth: true - spacing: -4 + WrappedLoader { + id: storageCard - CustomText { - Layout.alignment: Qt.AlignRight - color: batteryTank.accentColor - font.pointSize: Appearance.font.size.extraLarge - font.weight: Font.Medium - text: `${Math.round(batteryTank.percentage * 100)}%` + active: Config.dashboard.performance.showStorage + + sourceComponent: StorageCard { + } } - 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"); + WrappedLoader { + id: memoryCard - if (batteryTank.isCharging) - return qsTr("Charging"); + active: Config.dashboard.performance.showMemory - const s = UPower.displayDevice.timeToEmpty; - if (s === 0) - return qsTr("..."); + sourceComponent: MemoryCard { + } + } - const hr = Math.floor(s / 3600); - const min = Math.floor((s % 3600) / 60); - if (hr > 0) - return `${hr}h ${min}m`; + WrappedLoader { + id: networkCard - return `${min}m`; + active: Config.dashboard.performance.showNetwork + + sourceComponent: NetworkCard { } } } } - } - component CardHeader: RowLayout { - property color accentColor: DynamicColors.palette.m3primary - property string icon - property string title + WrappedLoader { + Layout.fillWidth: false + active: Battery.isLaptop && Config.dashboard.performance.showBattery + + sourceComponent: BatteryTank { + } + } + } + + component WrappedLoader: Loader { + Layout.fillHeight: true 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 - Appearance.padding.normal - - 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 - - ArcGauge { - accentColor: gaugeCard.accentColor - anchors.centerIn: parent - height: width - percentage: gaugeCard.animatedPercentage - startAngle: gaugeCard.arcStartAngle - sweepAngle: gaugeCard.arcSweep - trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - width: Math.min(parent.width, parent.height) - } - - 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 - Appearance.padding.normal - - 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) - implicitWidth: parent.width * heroCard.animatedUsage - } - - CardHeader { - accentColor: heroCard.accentColor - anchors.left: parent.left - anchors.leftMargin: Appearance.padding.large - anchors.top: parent.top - anchors.topMargin: Math.round(Appearance.padding.large * 1.2) - icon: heroCard.icon - title: heroCard.title - width: parent.width - anchors.leftMargin - usageColumn.anchors.rightMargin - usageLabel.width - Appearance.spacing.normal - } - - Column { - anchors.bottom: parent.bottom - anchors.bottomMargin: Math.round(Appearance.padding.large * 1.3) - anchors.left: parent.left - anchors.margins: Math.round(Appearance.padding.large * 1.2) - anchors.right: parent.right - 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 - implicitHeight: 6 - implicitWidth: parent.width * 0.5 - value: heroCard.tempProgress - } - } - - Column { - id: usageColumn - - anchors.margins: Appearance.padding.large - anchors.right: parent.right - anchors.rightMargin: 32 - anchors.verticalCenter: parent.verticalCenter - spacing: 0 - - CustomText { - id: usageLabel - - 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 - Appearance.padding.normal - - 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 - - SparklineItem { - id: sparkline - - property real smoothMax: targetMax - property real targetMax: 1024 - - anchors.fill: parent - historyLength: NetworkUsage.historyLength - line1: NetworkUsage.uploadBuffer // qmllint disable missing-type - line1Color: DynamicColors.palette.m3secondary - line1FillAlpha: 0.15 - line2: NetworkUsage.downloadBuffer // qmllint disable missing-type - line2Color: DynamicColors.palette.m3tertiary - line2FillAlpha: 0.2 - maxValue: smoothMax - - Behavior on smoothMax { - Anim { - duration: Appearance.anim.durations.large - } - } - - Connections { - function onValuesChanged(): void { - sparkline.targetMax = Math.max(NetworkUsage.downloadBuffer.maximum, NetworkUsage.uploadBuffer.maximum, 1024); - slideAnim.restart(); - } - - target: NetworkUsage.downloadBuffer - } - - NumberAnimation { - id: slideAnim - - duration: Config.dashboard.resourceUpdateInterval - from: 0 - property: "slideProgress" - target: sparkline - to: 1 - } - } - - // "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.downloadBuffer.count < 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 - Appearance.padding.normal - - 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 - - ArcGauge { - accentColor: storageGaugeCard.accentColor - anchors.centerIn: parent - height: width - percentage: storageGaugeCard.animatedPercentage - startAngle: storageGaugeCard.arcStartAngle - sweepAngle: storageGaugeCard.arcSweep - trackColor: DynamicColors.layer(DynamicColors.palette.m3surfaceContainerHigh, 2) - width: Math.min(parent.width, parent.height) - } - - 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}`; - } - } - } + visible: active } } diff --git a/Modules/Resources/Wrapper.qml b/Modules/Resources/Wrapper.qml index 7a277f5..af73e35 100644 --- a/Modules/Resources/Wrapper.qml +++ b/Modules/Resources/Wrapper.qml @@ -34,8 +34,6 @@ Item { anchors.centerIn: parent sourceComponent: Content { - padding: Appearance.padding.normal - visibilities: root.visibilities } } } diff --git a/Plugins/ZShell/CMakeLists.txt b/Plugins/ZShell/CMakeLists.txt index 0217b06..88c1263 100644 --- a/Plugins/ZShell/CMakeLists.txt +++ b/Plugins/ZShell/CMakeLists.txt @@ -1,14 +1,25 @@ find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus) find_package(PkgConfig REQUIRED) +find_library(SENSORS_LIBRARY NAMES sensors REQUIRED) +find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED) pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED) pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED) pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED) pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET) pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0) + if(NOT Cava_FOUND) pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED) endif() +if(NOT TARGET Sensors::Sensors) + add_library(Sensors::Sensors UNKNOWN IMPORTED) + set_target_properties(Sensors::Sensors PROPERTIES + IMPORTED_LOCATION "${SENSORS_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}" + ) +endif() + set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") qt_standard_project_setup(REQUIRES 6.9) diff --git a/Plugins/ZShell/Internal/CMakeLists.txt b/Plugins/ZShell/Internal/CMakeLists.txt index 93f1ca9..94b1c64 100644 --- a/Plugins/ZShell/Internal/CMakeLists.txt +++ b/Plugins/ZShell/Internal/CMakeLists.txt @@ -1,20 +1,22 @@ qml_module(ZShell-internal - URI ZShell.Internal - SOURCES - hyprextras.hpp hyprextras.cpp - hyprdevices.hpp hyprdevices.cpp - cachingimagemanager.hpp cachingimagemanager.cpp + URI ZShell.Internal + SOURCES + hyprextras.hpp hyprextras.cpp + hyprdevices.hpp hyprdevices.cpp + cachingimagemanager.hpp cachingimagemanager.cpp circularindicatormanager.hpp circularindicatormanager.cpp circularbuffer.hpp circularbuffer.cpp sparklineitem.hpp sparklineitem.cpp arcgauge.hpp arcgauge.cpp wallpaperimage.hpp wallpaperimage.cpp lidwatcher.hpp lidwatcher.cpp - LIBRARIES - Qt::Gui - Qt::Quick - Qt::Concurrent - Qt::Core + visualizerbars.hpp visualizerbars.cpp + linearindicatormanager.hpp linearindicatormanager.cpp + LIBRARIES + Qt::Gui + Qt::Quick + Qt::Concurrent + Qt::Core Qt::Network Qt::DBus ) diff --git a/Plugins/ZShell/Internal/linearindicatormanager.cpp b/Plugins/ZShell/Internal/linearindicatormanager.cpp new file mode 100644 index 0000000..655ed80 --- /dev/null +++ b/Plugins/ZShell/Internal/linearindicatormanager.cpp @@ -0,0 +1,118 @@ +#include "linearindicatormanager.hpp" + +#include + +namespace { + +constexpr int TOTAL_DURATION_IN_MS = 1800; +constexpr std::array DURATION_TO_MOVE_SEGMENT_ENDS = { 533, 567, 850, 750 }; +constexpr std::array DELAY_TO_MOVE_SEGMENT_ENDS = { 1267, 1000, 333, 0 }; + +QEasingCurve curve(const QPointF& c1, const QPointF& c2) { + QEasingCurve curve(QEasingCurve::BezierSpline); + curve.addCubicBezierSegment(c1, c2, { 1.0, 1.0 }); + return curve; +} + +qreal getFractionInRange(qreal playtime, int start, int duration) { + const auto fraction = static_cast(playtime - start) / duration; + return std::clamp(fraction, 0.0, 1.0); +} + +} // namespace + +namespace ZShell::controls { + +LinearIndicatorSegment::LinearIndicatorSegment(int gap, QObject* parent) + : QObject(parent) + , m_startFraction(0) + , m_endFraction(0) + , m_gapSize(gap) { +} + +qreal LinearIndicatorSegment::startFraction() const { + return m_startFraction; +} + +qreal LinearIndicatorSegment::endFraction() const { + return m_endFraction; +} + +int LinearIndicatorSegment::gapSize() const { + return m_gapSize; +} + +LinearIndicatorManager::LinearIndicatorManager(QObject* parent) + : QObject(parent) + , m_interpolators({ + curve({ 0.2, 0.0 }, { 0.8, 1.0 }), + curve({ 0.4, 0.0 }, { 1.0, 1.0 }), + curve({ 0.0, 0.0 }, { 0.65, 1.0 }), + curve({ 0.1, 0.0 }, { 0.45, 1.0 }), + }) + , m_progress(0) + , m_completeEndProgress(0) + , m_gap(4) + , m_activeIndicators({ + new LinearIndicatorSegment(m_gap, this), + new LinearIndicatorSegment(m_gap, this), + }) { + for (auto el : m_activeIndicators) + QObject::connect(this, &LinearIndicatorManager::updated, el, &LinearIndicatorSegment::updated); +} + +QList LinearIndicatorManager::activeIndicators() const { + return { m_activeIndicators.cbegin(), m_activeIndicators.cend() }; +} + +qreal LinearIndicatorManager::progress() const { + return m_progress; +} + +qreal LinearIndicatorManager::completeEndProgress() const { + return m_completeEndProgress; +} + +int LinearIndicatorManager::gap() const { + return m_gap; +} + +void LinearIndicatorManager::setGap(int gap) { + m_gap = gap; + for (auto el : m_activeIndicators) + el->m_gapSize = m_gap; + update(m_progress); +} + +int LinearIndicatorManager::duration() const { + return TOTAL_DURATION_IN_MS; +} + +int LinearIndicatorManager::completeEndDuration() const { + return TOTAL_DURATION_IN_MS; +} + +void LinearIndicatorManager::update(qreal progress) { + const auto playtime = progress * TOTAL_DURATION_IN_MS; + for (size_t i = 0; i < SEGMENTS; i++) { + const auto di = i * 2; + auto* const indicator = m_activeIndicators[i]; + + auto fraction = getFractionInRange(playtime, DELAY_TO_MOVE_SEGMENT_ENDS[di], DURATION_TO_MOVE_SEGMENT_ENDS[di]); + indicator->m_startFraction = std::clamp(m_interpolators[di].valueForProgress(fraction), 0.0, 1.0); + + fraction = + getFractionInRange(playtime, DELAY_TO_MOVE_SEGMENT_ENDS[di + 1], DURATION_TO_MOVE_SEGMENT_ENDS[di + 1]); + indicator->m_endFraction = std::clamp(m_interpolators[di + 1].valueForProgress(fraction), 0.0, 1.0); + } + + m_progress = progress; + emit updated(); +} + +void LinearIndicatorManager::updateCompleteEndProgress(qreal progress) { + m_completeEndProgress = progress; + update(m_progress); +} + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Internal/linearindicatormanager.hpp b/Plugins/ZShell/Internal/linearindicatormanager.hpp new file mode 100644 index 0000000..dd2f48c --- /dev/null +++ b/Plugins/ZShell/Internal/linearindicatormanager.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::controls { + +class LinearIndicatorManager; + +class LinearIndicatorSegment : public QObject { +Q_OBJECT +QML_ELEMENT + QML_UNCREATABLE("LinearIndicatorSegments can only be retrieved from a " + "LinearIndicatorManager.") + +Q_PROPERTY(qreal startFraction READ startFraction NOTIFY updated FINAL) +Q_PROPERTY(qreal endFraction READ endFraction NOTIFY updated FINAL) +Q_PROPERTY(int gapSize READ gapSize NOTIFY updated FINAL) + +public: +explicit LinearIndicatorSegment(int gap, QObject* parent = nullptr); + +qreal startFraction() const; +qreal endFraction() const; +int gapSize() const; + +signals: +void updated(); + +private: +qreal m_startFraction; +qreal m_endFraction; +int m_gapSize; + +friend LinearIndicatorManager; +}; + +class LinearIndicatorManager : public QObject { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY( + QList activeIndicators READ activeIndicators CONSTANT FINAL) + +Q_PROPERTY(qreal progress READ progress WRITE update NOTIFY updated FINAL) +Q_PROPERTY(qreal completeEndProgress READ completeEndProgress WRITE updateCompleteEndProgress NOTIFY updated FINAL) +Q_PROPERTY(int gap READ gap WRITE setGap NOTIFY updated FINAL) + +Q_PROPERTY(qreal duration READ duration CONSTANT FINAL) +Q_PROPERTY(qreal completeEndDuration READ completeEndDuration CONSTANT FINAL) + +public: +explicit LinearIndicatorManager(QObject* parent = nullptr); + +QList activeIndicators() const; + +qreal progress() const; +qreal completeEndProgress() const; + +int gap() const; +void setGap(int gap); + +int duration() const; +int completeEndDuration() const; + +void update(qreal progress); +void updateCompleteEndProgress(qreal progress); + +signals: +void updated(); + +private: +static constexpr int SEGMENTS = 2; + +std::array m_interpolators; +qreal m_progress; +qreal m_completeEndProgress; +int m_gap; + +std::array m_activeIndicators; +}; + +} // namespace ZShell::controls diff --git a/Plugins/ZShell/Internal/visualizerbars.cpp b/Plugins/ZShell/Internal/visualizerbars.cpp new file mode 100644 index 0000000..fb77d70 --- /dev/null +++ b/Plugins/ZShell/Internal/visualizerbars.cpp @@ -0,0 +1,198 @@ +#include "visualizerbars.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ZShell::internal { + +VisualizerBars::VisualizerBars(QQuickItem* parent) + : QQuickPaintedItem(parent) { + setAntialiasing(true); +} + +void VisualizerBars::advance(qreal dt) { + if (m_displayValues.isEmpty() || m_settled) + return; + + // dt is in seconds (from FrameAnimation.frameTime), convert to ms + const qreal dtMs = dt * 1000.0; + const qreal tau = m_animationDuration / 3.0; + const qreal alpha = 1.0 - std::exp(-dtMs / tau); + + bool allSettled = true; + + for (qsizetype i = 0; i < m_displayValues.size(); ++i) { + const double diff = m_targetValues[i] - m_displayValues[i]; + + if (std::abs(diff) > 0.001) { + m_displayValues[i] += diff * alpha; + allSettled = false; + } else { + m_displayValues[i] = m_targetValues[i]; + } + } + + update(); + + if (allSettled && !m_settled) { + m_settled = true; + emit settledChanged(); + } +} + +void VisualizerBars::paint(QPainter* painter) { + if (m_displayValues.isEmpty()) + return; + + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setPen(Qt::NoPen); + + const qreal h = height(); + const qreal maxBarHeight = h * 0.4; + + QLinearGradient gradient(0, h - maxBarHeight, 0, h); + gradient.setColorAt(0, m_primaryColor); + gradient.setColorAt(1, m_secondaryColor); + painter->setBrush(gradient); + + drawSide(painter, false); + drawSide(painter, true); +} + +void VisualizerBars::drawSide(QPainter* painter, bool rightSide) { + const qreal w = width(); + const qreal h = height(); + const auto count = m_displayValues.size(); + + if (count == 0) + return; + + const qreal sideWidth = w * 0.4; + const qreal slotWidth = sideWidth / static_cast(count); + const qreal barWidth = slotWidth - m_spacing; + + if (barWidth <= 0) + return; + + const qreal sideOffset = rightSide ? w * 0.6 : 0; + const qreal maxBarHeight = h * 0.4; + + for (qsizetype i = 0; i < count; ++i) { + const qsizetype valueIndex = rightSide ? i : (count - i - 1); + const qreal value = std::clamp(m_displayValues[valueIndex], 0.0, 1.0); + const qreal barHeight = value * maxBarHeight; + + if (barHeight <= 0) + continue; + + const qreal x = static_cast(i) * slotWidth + sideOffset; + const qreal y = h - barHeight; + const qreal r = std::min({ m_rounding, barWidth / 2.0, barHeight }); + + QPainterPath path; + path.moveTo(x, h); + path.lineTo(x, y + r); + + if (r > 0) { + path.arcTo(x, y, r * 2, r * 2, 180, -90); + path.lineTo(x + barWidth - r, y); + path.arcTo(x + barWidth - r * 2, y, r * 2, r * 2, 90, -90); + } else { + path.lineTo(x, y); + path.lineTo(x + barWidth, y); + } + + path.lineTo(x + barWidth, h); + path.closeSubpath(); + + painter->drawPath(path); + } +} + +QVector VisualizerBars::values() const { + return m_targetValues; +} + +void VisualizerBars::setValues(const QVector& values) { + m_targetValues = values; + + if (m_displayValues.size() != values.size()) { + m_displayValues.resize(values.size(), 0.0); + } + + if (m_settled) { + m_settled = false; + emit settledChanged(); + } + + emit valuesChanged(); +} + +bool VisualizerBars::settled() const { + return m_settled; +} + +QColor VisualizerBars::primaryColor() const { + return m_primaryColor; +} + +void VisualizerBars::setPrimaryColor(const QColor& color) { + if (m_primaryColor == color) + return; + m_primaryColor = color; + emit primaryColorChanged(); + update(); +} + +QColor VisualizerBars::secondaryColor() const { + return m_secondaryColor; +} + +void VisualizerBars::setSecondaryColor(const QColor& color) { + if (m_secondaryColor == color) + return; + m_secondaryColor = color; + emit secondaryColorChanged(); + update(); +} + +qreal VisualizerBars::rounding() const { + return m_rounding; +} + +void VisualizerBars::setRounding(qreal rounding) { + if (qFuzzyCompare(m_rounding, rounding)) + return; + m_rounding = rounding; + emit roundingChanged(); + update(); +} + +qreal VisualizerBars::spacing() const { + return m_spacing; +} + +void VisualizerBars::setSpacing(qreal spacing) { + if (qFuzzyCompare(m_spacing, spacing)) + return; + m_spacing = spacing; + emit spacingChanged(); + update(); +} + +int VisualizerBars::animationDuration() const { + return m_animationDuration; +} + +void VisualizerBars::setAnimationDuration(int duration) { + if (m_animationDuration == duration) + return; + m_animationDuration = duration; + emit animationDurationChanged(); +} + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Internal/visualizerbars.hpp b/Plugins/ZShell/Internal/visualizerbars.hpp new file mode 100644 index 0000000..64651cd --- /dev/null +++ b/Plugins/ZShell/Internal/visualizerbars.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace ZShell::internal { + +class VisualizerBars : public QQuickPaintedItem { +Q_OBJECT +QML_ELEMENT + +Q_PROPERTY(QVector values READ values WRITE setValues NOTIFY valuesChanged) +Q_PROPERTY(QColor primaryColor READ primaryColor WRITE setPrimaryColor NOTIFY primaryColorChanged) +Q_PROPERTY(QColor secondaryColor READ secondaryColor WRITE setSecondaryColor NOTIFY secondaryColorChanged) +Q_PROPERTY(qreal rounding READ rounding WRITE setRounding NOTIFY roundingChanged) +Q_PROPERTY(qreal spacing READ spacing WRITE setSpacing NOTIFY spacingChanged) +Q_PROPERTY(int animationDuration READ animationDuration WRITE setAnimationDuration NOTIFY animationDurationChanged) +Q_PROPERTY(bool settled READ settled NOTIFY settledChanged) + +public: +explicit VisualizerBars(QQuickItem* parent = nullptr); + +void paint(QPainter* painter) override; + +Q_INVOKABLE void advance(qreal dt); + +[[nodiscard]] QVector values() const; +void setValues(const QVector& values); + +[[nodiscard]] QColor primaryColor() const; +void setPrimaryColor(const QColor& color); + +[[nodiscard]] QColor secondaryColor() const; +void setSecondaryColor(const QColor& color); + +[[nodiscard]] qreal rounding() const; +void setRounding(qreal rounding); + +[[nodiscard]] qreal spacing() const; +void setSpacing(qreal spacing); + +[[nodiscard]] int animationDuration() const; +void setAnimationDuration(int duration); + +[[nodiscard]] bool settled() const; + +signals: +void valuesChanged(); +void primaryColorChanged(); +void secondaryColorChanged(); +void roundingChanged(); +void spacingChanged(); +void animationDurationChanged(); +void settledChanged(); + +private: +void drawSide(QPainter* painter, bool rightSide); + +QVector m_targetValues; +QVector m_displayValues; +QColor m_primaryColor; +QColor m_secondaryColor; +qreal m_rounding = 0.0; +qreal m_spacing = 0.0; +int m_animationDuration = 200; +bool m_settled = true; +}; + +} // namespace ZShell::internal diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index f45ecdf..0ff7714 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -1,19 +1,27 @@ qml_module(ZShell-services URI ZShell.Services - SOURCES - service.hpp service.cpp - serviceref.hpp serviceref.cpp - beattracker.hpp beattracker.cpp - audiocollector.hpp audiocollector.cpp - audioprovider.hpp audioprovider.cpp - cavaprovider.hpp cavaprovider.cpp + SOURCES + service.hpp service.cpp + serviceref.hpp serviceref.cpp + beattracker.hpp beattracker.cpp + audiocollector.hpp audiocollector.cpp + audioprovider.hpp audioprovider.cpp + cavaprovider.hpp cavaprovider.cpp desktopmodel.hpp desktopmodel.cpp desktopstatemanager.hpp desktopstatemanager.cpp hyprsunsetmanager.hpp hyprsunsetmanager.cpp - LIBRARIES + tickingservice.hpp tickingservice.cpp + sensorslib.hpp sensorslib.cpp + usagefmt.hpp usagefmt.cpp + cpu.hpp cpu.cpp + memory.hpp memory.cpp + diskinfo.hpp diskinfo.cpp + storage.hpp storage.cpp + LIBRARIES Qt6::Core Qt6::Qml - PkgConfig::Pipewire - PkgConfig::Aubio - PkgConfig::Cava + PkgConfig::Pipewire + PkgConfig::Aubio + PkgConfig::Cava + Sensors::Sensors ) diff --git a/Plugins/ZShell/Services/cpu.cpp b/Plugins/ZShell/Services/cpu.cpp new file mode 100644 index 0000000..c04f356 --- /dev/null +++ b/Plugins/ZShell/Services/cpu.cpp @@ -0,0 +1,117 @@ +#include "cpu.hpp" + +#include "sensorslib.hpp" + +#include +#include +#include + +namespace ZShell::services { + +Cpu::Cpu(QObject* parent) + : TickingService(parent) { + readNameOnce(); +} + +QString Cpu::name() const { + return m_name; +} + +qreal Cpu::percentage() const { + return m_percentage; +} + +qreal Cpu::temperature() const { + return m_temperature; +} + +void Cpu::tick() { + if (!m_nameLoaded) { + readNameOnce(); + } + refreshPercentage(); + refreshTemperature(); +} + +void Cpu::readNameOnce() { + QFile f(QStringLiteral("/proc/cpuinfo")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + const QByteArray data = f.readAll(); + f.close(); + + static const QRegularExpression re(QStringLiteral("model name\\s*:\\s*(.+)")); + const auto match = re.match(QString::fromLatin1(data)); + if (!match.hasMatch()) { + return; + } + + const QString cleaned = cleanName(match.captured(1)); + m_nameLoaded = true; + if (cleaned == m_name) { + return; + } + m_name = cleaned; + Q_EMIT nameChanged(); +} + +void Cpu::refreshPercentage() { + QFile f(QStringLiteral("/proc/stat")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + const QByteArray data = f.readAll(); + f.close(); + + static const QRegularExpression re( + QStringLiteral("^cpu\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)\\s+(\\d+)")); + const auto match = re.match(QString::fromLatin1(data)); + if (!match.hasMatch()) { + return; + } + + quint64 total = 0; + quint64 idle = 0; + for (int i = 1; i <= 7; ++i) { + const quint64 v = match.captured(i).toULongLong(); + total += v; + if (i == 4 || i == 5) { + idle += v; + } + } + + const quint64 totalDiff = total > m_lastTotal ? total - m_lastTotal : 0; + const quint64 idleDiff = idle > m_lastIdle ? idle - m_lastIdle : 0; + const qreal newPerc = totalDiff > 0 ? 1.0 - static_cast(idleDiff) / static_cast(totalDiff) : 0.0; + + m_lastTotal = total; + m_lastIdle = idle; + + if (std::abs(newPerc - m_percentage) > 0.0001) { + m_percentage = newPerc; + Q_EMIT percentageChanged(); + } +} + +void Cpu::refreshTemperature() { + const auto t = sensorslib::cpuPackageTemp(); + const qreal newTemp = t.value_or(0.0); + if (std::abs(newTemp - m_temperature) > 0.05) { + m_temperature = newTemp; + Q_EMIT temperatureChanged(); + } +} + +QString Cpu::cleanName(QString s) { + static const QRegularExpression noise( + QStringLiteral("\\(R\\)|\\(TM\\)|CPU|\\d+(?:th|nd|rd|st) Gen |Core |Processor"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression spaces(QStringLiteral("\\s+")); + + s.replace(noise, QString()); + s.replace(spaces, QStringLiteral(" ")); + return s.trimmed(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/cpu.hpp b/Plugins/ZShell/Services/cpu.hpp new file mode 100644 index 0000000..eb3909c --- /dev/null +++ b/Plugins/ZShell/Services/cpu.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "tickingservice.hpp" + +#include + +namespace ZShell::services { + +class Cpu : public TickingService { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +Q_PROPERTY(QString name READ name NOTIFY nameChanged) +Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) +Q_PROPERTY(qreal temperature READ temperature NOTIFY temperatureChanged) + +public: +explicit Cpu(QObject* parent = nullptr); + +[[nodiscard]] QString name() const; +[[nodiscard]] qreal percentage() const; +[[nodiscard]] qreal temperature() const; + +signals: +void nameChanged(); +void percentageChanged(); +void temperatureChanged(); + +protected: +void tick() override; + +private: +void readNameOnce(); +void refreshPercentage(); +void refreshTemperature(); + +[[nodiscard]] static QString cleanName(QString s); + +QString m_name; +qreal m_percentage = 0.0; +qreal m_temperature = 0.0; +quint64 m_lastIdle = 0; +quint64 m_lastTotal = 0; +bool m_nameLoaded = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/diskinfo.cpp b/Plugins/ZShell/Services/diskinfo.cpp new file mode 100644 index 0000000..aca9bb8 --- /dev/null +++ b/Plugins/ZShell/Services/diskinfo.cpp @@ -0,0 +1,68 @@ +#include "diskinfo.hpp" + +namespace ZShell::services { + +namespace { + +constexpr qreal kKib = 1024.0; + +} // namespace + +DiskInfo::DiskInfo(QString mount, quint64 usedBytes, quint64 totalBytes, bool hasRoot, QObject* parent) + : QObject(parent) + , m_mount(std::move(mount)) + , m_usedBytes(usedBytes) + , m_totalBytes(totalBytes) + , m_hasRoot(hasRoot) { +} + +QString DiskInfo::mount() const { + return m_mount; +} + +qreal DiskInfo::used() const { + return static_cast(m_usedBytes) / kKib; +} + +qreal DiskInfo::total() const { + return static_cast(m_totalBytes) / kKib; +} + +qreal DiskInfo::free() const { + const quint64 freeBytes = m_totalBytes > m_usedBytes ? m_totalBytes - m_usedBytes : 0; + return static_cast(freeBytes) / kKib; +} + +qreal DiskInfo::perc() const { + return m_totalBytes > 0 ? static_cast(m_usedBytes) / static_cast(m_totalBytes) : 0.0; +} + +bool DiskInfo::hasRoot() const { + return m_hasRoot; +} + +void DiskInfo::update(quint64 usedBytes, quint64 totalBytes, bool hasRoot) { + const bool usedDiff = usedBytes != m_usedBytes; + const bool totalDiff = totalBytes != m_totalBytes; + const bool rootDiff = hasRoot != m_hasRoot; + + m_usedBytes = usedBytes; + m_totalBytes = totalBytes; + m_hasRoot = hasRoot; + + if (usedDiff) { + Q_EMIT usedChanged(); + } + if (totalDiff) { + Q_EMIT totalChanged(); + } + if (usedDiff || totalDiff) { + Q_EMIT freeChanged(); + Q_EMIT percChanged(); + } + if (rootDiff) { + Q_EMIT hasRootChanged(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/diskinfo.hpp b/Plugins/ZShell/Services/diskinfo.hpp new file mode 100644 index 0000000..34acdff --- /dev/null +++ b/Plugins/ZShell/Services/diskinfo.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +namespace ZShell::services { + +class DiskInfo : public QObject { +Q_OBJECT +QML_ELEMENT + QML_UNCREATABLE("DiskInfo is created by DiskUsage") + +Q_PROPERTY(QString mount READ mount CONSTANT) +Q_PROPERTY(qreal used READ used NOTIFY usedChanged) +Q_PROPERTY(qreal total READ total NOTIFY totalChanged) +Q_PROPERTY(qreal free READ free NOTIFY freeChanged) +Q_PROPERTY(qreal perc READ perc NOTIFY percChanged) +Q_PROPERTY(bool hasRoot READ hasRoot NOTIFY hasRootChanged) + +public: +DiskInfo(QString mount, quint64 usedBytes, quint64 totalBytes, bool hasRoot, QObject* parent = nullptr); + +[[nodiscard]] QString mount() const; +[[nodiscard]] qreal used() const; +[[nodiscard]] qreal total() const; +[[nodiscard]] qreal free() const; +[[nodiscard]] qreal perc() const; +[[nodiscard]] bool hasRoot() const; + +void update(quint64 usedBytes, quint64 totalBytes, bool hasRoot); + +signals: +void usedChanged(); +void totalChanged(); +void freeChanged(); +void percChanged(); +void hasRootChanged(); + +private: +QString m_mount; +quint64 m_usedBytes; +quint64 m_totalBytes; +bool m_hasRoot; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/memory.cpp b/Plugins/ZShell/Services/memory.cpp new file mode 100644 index 0000000..50f5f77 --- /dev/null +++ b/Plugins/ZShell/Services/memory.cpp @@ -0,0 +1,59 @@ +#include "memory.hpp" + +#include +#include + +namespace ZShell::services { + +Memory::Memory(QObject* parent) + : TickingService(parent) { +} + +qreal Memory::used() const { + return m_used; +} + +qreal Memory::total() const { + return m_total; +} + +qreal Memory::percentage() const { + return m_total > 0.0 ? m_used / m_total : 0.0; +} + +void Memory::tick() { + QFile f(QStringLiteral("/proc/meminfo")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return; + } + const QByteArray data = f.readAll(); + f.close(); + + static const QRegularExpression reTotal(QStringLiteral("MemTotal: *(\\d+)")); + static const QRegularExpression reAvail(QStringLiteral("MemAvailable: *(\\d+)")); + const QString text = QString::fromLatin1(data); + + const auto totalMatch = reTotal.match(text); + const auto availMatch = reAvail.match(text); + if (!totalMatch.hasMatch() || !availMatch.hasMatch()) { + return; + } + + const quint64 totalKib = totalMatch.captured(1).toULongLong(); + const quint64 availKib = availMatch.captured(1).toULongLong(); + if (totalKib == 0) { + return; + } + const quint64 usedKib = totalKib > availKib ? totalKib - availKib : 0; + + if (totalKib == m_lastTotal && usedKib == m_lastUsed) { + return; + } + m_lastTotal = totalKib; + m_lastUsed = usedKib; + m_total = static_cast(totalKib); + m_used = static_cast(usedKib); + Q_EMIT changed(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/memory.hpp b/Plugins/ZShell/Services/memory.hpp new file mode 100644 index 0000000..f5d0fb6 --- /dev/null +++ b/Plugins/ZShell/Services/memory.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "tickingservice.hpp" + +#include +#include + +namespace ZShell::services { + +class Memory : public TickingService { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +Q_PROPERTY(qreal used READ used NOTIFY changed) +Q_PROPERTY(qreal total READ total NOTIFY changed) +Q_PROPERTY(qreal percentage READ percentage NOTIFY changed) + +public: +explicit Memory(QObject* parent = nullptr); + +[[nodiscard]] qreal used() const; +[[nodiscard]] qreal total() const; +[[nodiscard]] qreal percentage() const; + +signals: +void changed(); + +protected: +void tick() override; + +private: +qreal m_used = 0.0; +qreal m_total = 1.0; +quint64 m_lastUsed = 0; +quint64 m_lastTotal = 0; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/sensorslib.cpp b/Plugins/ZShell/Services/sensorslib.cpp new file mode 100644 index 0000000..995434a --- /dev/null +++ b/Plugins/ZShell/Services/sensorslib.cpp @@ -0,0 +1,166 @@ +#include "sensorslib.hpp" + +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcSensorsLib, "ZShell.services.sensorslib", QtInfoMsg) + +namespace ZShell::services::sensorslib { + +namespace { + +std::atomic g_initOk{ false }; +std::once_flag g_initFlag; + +void doInit() { + if (sensors_init(nullptr) != 0) { + qCWarning(lcSensorsLib, "sensors_init failed"); + g_initOk.store(false, std::memory_order_release); + return; + } + g_initOk.store(true, std::memory_order_release); + std::atexit([] { + if (g_initOk.load(std::memory_order_acquire)) { + sensors_cleanup(); + } + }); +} + +[[nodiscard]] std::optional readTempInput(const sensors_chip_name* chip, const sensors_feature* feat) { + const sensors_subfeature* sf = sensors_get_subfeature(chip, feat, SENSORS_SUBFEATURE_TEMP_INPUT); + if (!sf) { + return std::nullopt; + } + double value = 0.0; + if (sensors_get_value(chip, sf->number, &value) != 0) { + return std::nullopt; + } + return value; +} + +[[nodiscard]] QByteArray featureLabel(const sensors_chip_name* chip, const sensors_feature* feat) { + char* raw = sensors_get_label(chip, feat); + if (!raw) { + return {}; + } + QByteArray out(raw); + std::free(raw); + return out; +} + +bool labelEquals(const QByteArray& label, const char* literal) { + return label == QByteArrayView(literal); +} + +bool labelStartsWith(const QByteArray& label, const char* prefix) { + const auto n = std::strlen(prefix); + return static_cast(label.size()) >= n && std::memcmp(label.constData(), prefix, n) == 0; +} + +} // namespace + +void ensureInit() { + std::call_once(g_initFlag, doInit); +} + +std::optional cpuPackageTemp() { + ensureInit(); + if (!g_initOk.load(std::memory_order_acquire)) { + return std::nullopt; + } + + std::optional primary; // Package id N / Tdie + std::optional fallback; // Tctl + + int chipNr = 0; + while (const sensors_chip_name* chip = sensors_get_detected_chips(nullptr, &chipNr)) { + int featNr = 0; + while (const sensors_feature* feat = sensors_get_features(chip, &featNr)) { + if (feat->type != SENSORS_FEATURE_TEMP) { + continue; + } + const QByteArray label = featureLabel(chip, feat); + if (label.isEmpty()) { + continue; + } + + if (labelStartsWith(label, "Package id ") || labelEquals(label, "Tdie")) { + if (auto v = readTempInput(chip, feat)) { + primary = v; + } + } else if (labelEquals(label, "Tctl")) { + if (auto v = readTempInput(chip, feat)) { + fallback = v; + } + } + } + } + + return primary.has_value() ? primary : fallback; +} + +std::optional gpuPciAverageTemp() { + ensureInit(); + if (!g_initOk.load(std::memory_order_acquire)) { + return std::nullopt; + } + + double sumPrimary = 0.0; + int countPrimary = 0; + double sumFallback = 0.0; + int countFallback = 0; + + int chipNr = 0; + while (const sensors_chip_name* chip = sensors_get_detected_chips(nullptr, &chipNr)) { + if (chip->bus.type != SENSORS_BUS_TYPE_PCI) { + continue; + } + + int featNr = 0; + while (const sensors_feature* feat = sensors_get_features(chip, &featNr)) { + if (feat->type != SENSORS_FEATURE_TEMP) { + continue; + } + const QByteArray label = featureLabel(chip, feat); + if (label.isEmpty()) { + continue; + } + + const bool tempIndexed = labelStartsWith(label, "temp") && label.size() > 4 && + std::isdigit(static_cast(label[4])); + const bool isPrimary = tempIndexed || labelEquals(label, "GPU core") || labelEquals(label, "edge"); + const bool isFallback = labelEquals(label, "junction") || labelEquals(label, "mem"); + + if (!isPrimary && !isFallback) { + continue; + } + + const auto v = readTempInput(chip, feat); + if (!v) { + continue; + } + if (isPrimary) { + sumPrimary += *v; + ++countPrimary; + } else { + sumFallback += *v; + ++countFallback; + } + } + } + + if (countPrimary > 0) { + return sumPrimary / countPrimary; + } + if (countFallback > 0) { + return sumFallback / countFallback; + } + return std::nullopt; +} + +} // namespace ZShell::services::sensorslib diff --git a/Plugins/ZShell/Services/sensorslib.hpp b/Plugins/ZShell/Services/sensorslib.hpp new file mode 100644 index 0000000..ebb603d --- /dev/null +++ b/Plugins/ZShell/Services/sensorslib.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace ZShell::services::sensorslib { + +void ensureInit(); + +[[nodiscard]] std::optional cpuPackageTemp(); +[[nodiscard]] std::optional gpuPciAverageTemp(); + +} // namespace ZShell::services::sensorslib diff --git a/Plugins/ZShell/Services/storage.cpp b/Plugins/ZShell/Services/storage.cpp new file mode 100644 index 0000000..f9973c6 --- /dev/null +++ b/Plugins/ZShell/Services/storage.cpp @@ -0,0 +1,313 @@ +#include "storage.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(lcStorage, "ZShell.services.storage", QtInfoMsg) + +namespace ZShell::services { + +namespace { + +struct Accum { + quint64 usedBytes = 0; + quint64 totalBytes = 0; + bool hasRoot = false; +}; + +[[nodiscard]] QString sysfsRealPath(uint major, uint minor) { + const QString link = QStringLiteral("/sys/dev/block/%1:%2").arg(major).arg(minor); + const QString resolved = QFileInfo(link).canonicalFilePath(); + return resolved; +} + +[[nodiscard]] bool readDevtFromSysfs(const QString& sysfsBlockDir, uint& major, uint& minor) { + QFile f(sysfsBlockDir + QStringLiteral("/dev")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return false; + } + const QByteArray line = f.readLine().trimmed(); + f.close(); + + const qsizetype colon = line.indexOf(':'); + if (colon <= 0) { + return false; + } + bool okM = false; + bool okN = false; + major = line.left(colon).toUInt(&okM); + minor = line.mid(colon + 1).toUInt(&okN); + return okM && okN; +} + +QStringList resolveByDevt(uint major, uint minor, int depth = 0); + +QStringList resolveAtNode(const QString& node, int depth) { + if (node.isEmpty() || depth > 8) { + return {}; + } + + const QFileInfo nodeInfo(node); + if (!nodeInfo.exists() || !nodeInfo.isDir()) { + return {}; + } + + if (QFileInfo::exists(node + QStringLiteral("/partition"))) { + const QString diskNode = nodeInfo.path(); + return { QFileInfo(diskNode).fileName() }; + } + + const QDir slavesDir(node + QStringLiteral("/slaves")); + if (slavesDir.exists()) { + const QStringList slaves = slavesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + if (!slaves.isEmpty()) { + QStringList out; + for (const QString& slave : slaves) { + uint sm = 0; + uint sn = 0; + const QString slaveDir = QStringLiteral("/sys/class/block/") + slave; + if (!readDevtFromSysfs(slaveDir, sm, sn)) { + continue; + } + const auto devs = resolveByDevt(sm, sn, depth + 1); + for (const QString& d : devs) { + if (!out.contains(d)) { + out.append(d); + } + } + } + return out; + } + } + + return { nodeInfo.fileName() }; +} + +QStringList resolveByDevt(uint major, uint minor, int depth) { + return resolveAtNode(sysfsRealPath(major, minor), depth); +} + +} // namespace + +Storage::Storage(QObject* parent) + : TickingService(parent) { +} + +qreal Storage::percentage() const { + qreal totalUsed = 0.0; + qreal totalSize = 0.0; + for (const DiskInfo* d : m_disks) { + totalUsed += d->used(); + totalSize += d->total(); + } + return totalSize > 0.0 ? totalUsed / totalSize : 0.0; +} + +bool Storage::sameOrder(const QList& a, const QList& b) { + if (a.size() != b.size()) { + return false; + } + for (qsizetype i = 0; i < a.size(); ++i) { + if (a.at(i) != b.at(i)) { + return false; + } + } + return true; +} + +QQmlListProperty Storage::disksProp() { + return QQmlListProperty(this, nullptr, &Storage::disksCount, &Storage::disksAt); +} + +qsizetype Storage::disksCount(QQmlListProperty* prop) { + return static_cast(prop->object)->m_disks.size(); +} + +DiskInfo* Storage::disksAt(QQmlListProperty* prop, qsizetype i) { + return static_cast(prop->object)->m_disks.at(i); +} + +DiskInfo* Storage::manualPrimaryDisk() const { + return m_manualPrimaryDisk.data(); +} + +void Storage::setManualPrimaryDisk(DiskInfo* disk) { + if (m_manualPrimaryDisk.data() == disk) { + return; + } + m_manualPrimaryDisk = disk; + Q_EMIT manualPrimaryDiskChanged(); + Q_EMIT primaryDiskChanged(); +} + +DiskInfo* Storage::primaryDisk() const { + if (auto* m = m_manualPrimaryDisk.data()) { + return m; + } + return m_disks.isEmpty() ? nullptr : m_disks.first(); +} + +bool Storage::isPseudoFs(QByteArrayView fsType) { + static constexpr const char* kPseudo[] = { + "tmpfs", + "devtmpfs", + "proc", + "sysfs", + "cgroup", + "cgroup2", + "overlay", + "squashfs", + "devpts", + "mqueue", + "ramfs", + "rpc_pipefs", + "autofs", + "configfs", + "debugfs", + "tracefs", + "securityfs", + "pstore", + "bpf", + "binfmt_misc", + "hugetlbfs", + "fusectl", + "efivarfs", + "selinuxfs", + }; + for (const char* p : kPseudo) { + if (fsType == QByteArrayView(p)) { + return true; + } + } + return fsType.startsWith(QByteArrayView("fuse.")); +} + +QStringList Storage::resolveToPhysicalDisks(const QString& devicePath) { + if (devicePath.isEmpty() || !devicePath.startsWith(QLatin1Char('/'))) { + return {}; + } + struct stat st {}; + if (::stat(devicePath.toLocal8Bit().constData(), &st) != 0) { + return {}; + } + if (!S_ISBLK(st.st_mode)) { + return {}; + } + return resolveByDevt(major(st.st_rdev), minor(st.st_rdev)); +} + +void Storage::tick() { + const qreal prevPercentage = percentage(); + QHash byDisk; + + // Multiple mounts can share a single backing filesystem (btrfs subvolumes, + // bind mounts, etc.) and each one reports identical bytesTotal/bytesAvailable. + // Dedupe by source device so the filesystem only contributes once per disk. + struct DeviceEntry { + quint64 totalBytes = 0; + quint64 usedBytes = 0; + bool hasRoot = false; + QByteArray device; + }; + + QHash byDevice; + + const auto mountedVols = QStorageInfo::mountedVolumes(); + for (const QStorageInfo& v : mountedVols) { + if (!v.isReady() || !v.isValid() || v.bytesTotal() <= 0) { + continue; + } + if (isPseudoFs(QByteArrayView(v.fileSystemType()))) { + continue; + } + + const QByteArray device = v.device(); + const auto totalBytes = static_cast(v.bytesTotal()); + const auto availBytes = static_cast(v.bytesAvailable()); + const quint64 usedBytes = totalBytes > availBytes ? totalBytes - availBytes : 0; + const bool isRoot = v.rootPath() == QStringLiteral("/"); + + DeviceEntry& e = byDevice[device]; + e.device = device; + e.totalBytes = totalBytes; + e.usedBytes = usedBytes; + e.hasRoot = e.hasRoot || isRoot; + } + + for (auto it = byDevice.constBegin(); it != byDevice.constEnd(); ++it) { + const DeviceEntry& e = it.value(); + const QStringList disks = resolveToPhysicalDisks(QString::fromLocal8Bit(e.device)); + if (disks.isEmpty()) { + continue; + } + for (const QString& d : disks) { + if (d.startsWith(QStringLiteral("zram"))) { + continue; + } + Accum& a = byDisk[d]; + a.usedBytes += e.usedBytes; + a.totalBytes += e.totalBytes; + a.hasRoot = a.hasRoot || e.hasRoot; + } + } + + QHash existing; + existing.reserve(m_disks.size()); + for (DiskInfo* d : std::as_const(m_disks)) { + existing.insert(d->mount(), d); + } + + QList next; + next.reserve(byDisk.size()); + for (auto it = byDisk.constBegin(); it != byDisk.constEnd(); ++it) { + if (DiskInfo* survivor = existing.take(it.key())) { + survivor->update(it.value().usedBytes, it.value().totalBytes, it.value().hasRoot); + next.append(survivor); + } else { + next.append(new DiskInfo(it.key(), it.value().usedBytes, it.value().totalBytes, it.value().hasRoot, this)); + } + } + + std::sort(next.begin(), next.end(), [](const DiskInfo* a, const DiskInfo* b) { + if (a->hasRoot() != b->hasRoot()) { + return a->hasRoot(); + } + return a->mount() < b->mount(); + }); + + bool manualCleared = false; + if (DiskInfo* m = m_manualPrimaryDisk.data(); m && existing.contains(m->mount())) { + m_manualPrimaryDisk.clear(); + manualCleared = true; + } + for (DiskInfo* stale : std::as_const(existing)) { + stale->deleteLater(); + } + + const bool listChanged = !sameOrder(m_disks, next); + DiskInfo* prevPrimary = primaryDisk(); + m_disks = next; + + if (listChanged) { + Q_EMIT disksChanged(); + } + if (std::abs(percentage() - prevPercentage) > 0.0001) { + Q_EMIT percentageChanged(); + } + if (manualCleared) { + Q_EMIT manualPrimaryDiskChanged(); + } + if (primaryDisk() != prevPrimary) { + Q_EMIT primaryDiskChanged(); + } +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/storage.hpp b/Plugins/ZShell/Services/storage.hpp new file mode 100644 index 0000000..2138c04 --- /dev/null +++ b/Plugins/ZShell/Services/storage.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "diskinfo.hpp" +#include "tickingservice.hpp" + +#include +#include +#include +#include +#include + +namespace ZShell::services { + +class Storage : public TickingService { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +Q_PROPERTY(qreal percentage READ percentage NOTIFY percentageChanged) +Q_PROPERTY(QQmlListProperty disks READ disksProp NOTIFY disksChanged) +Q_PROPERTY(ZShell::services::DiskInfo* manualPrimaryDisk READ manualPrimaryDisk WRITE setManualPrimaryDisk NOTIFY + manualPrimaryDiskChanged) +Q_PROPERTY(ZShell::services::DiskInfo* primaryDisk READ primaryDisk NOTIFY primaryDiskChanged) + +public: +explicit Storage(QObject* parent = nullptr); + +[[nodiscard]] qreal percentage() const; +[[nodiscard]] QQmlListProperty disksProp(); +[[nodiscard]] DiskInfo* manualPrimaryDisk() const; +void setManualPrimaryDisk(DiskInfo* disk); +[[nodiscard]] DiskInfo* primaryDisk() const; + +signals: +void disksChanged(); +void percentageChanged(); +void manualPrimaryDiskChanged(); +void primaryDiskChanged(); + +protected: +void tick() override; + +private: +[[nodiscard]] static QStringList resolveToPhysicalDisks(const QString& devicePath); +[[nodiscard]] static bool isPseudoFs(QByteArrayView fsType); +[[nodiscard]] static bool sameOrder(const QList& a, const QList& b); + +static qsizetype disksCount(QQmlListProperty* prop); +static DiskInfo* disksAt(QQmlListProperty* prop, qsizetype i); + +QList m_disks; +QPointer m_manualPrimaryDisk; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.cpp b/Plugins/ZShell/Services/tickingservice.cpp new file mode 100644 index 0000000..5611a6c --- /dev/null +++ b/Plugins/ZShell/Services/tickingservice.cpp @@ -0,0 +1,42 @@ +#include "tickingservice.hpp" + +namespace ZShell::services { + +TickingService::TickingService(QObject* parent) + : Service(parent) + , m_timer(new QTimer(this)) { + m_timer->setSingleShot(false); + QObject::connect(m_timer, &QTimer::timeout, this, [this] { + tick(); + }); +} + +int TickingService::updateInterval() const { + return m_interval; +} + +void TickingService::start() { + m_running = true; + if (m_interval > 0) { + m_timer->start(m_interval); + } + tick(); +} + +void TickingService::stop() { + m_running = false; + m_timer->stop(); +} + +void TickingService::applyInterval(int ms) { + if (ms <= 0 || ms == m_interval) { + return; + } + m_interval = ms; + if (m_running) { + m_timer->start(m_interval); + } + Q_EMIT updateIntervalChanged(); +} + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/tickingservice.hpp b/Plugins/ZShell/Services/tickingservice.hpp new file mode 100644 index 0000000..95124eb --- /dev/null +++ b/Plugins/ZShell/Services/tickingservice.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "service.hpp" +#include + +namespace ZShell::services { + +class TickingService : public Service { +Q_OBJECT + +Q_PROPERTY(int updateInterval READ updateInterval NOTIFY updateIntervalChanged) + +public: +explicit TickingService(QObject* parent = nullptr); + +[[nodiscard]] int updateInterval() const; + +signals: +void updateIntervalChanged(); + +protected: +void start() final; +void stop() final; + +virtual void tick() = 0; + +private: +void applyInterval(int ms); + +QTimer* m_timer; +int m_interval = 1000; +bool m_running = false; +}; + +} // namespace ZShell::services diff --git a/Plugins/ZShell/Services/usagefmt.cpp b/Plugins/ZShell/Services/usagefmt.cpp new file mode 100644 index 0000000..e455fe8 --- /dev/null +++ b/Plugins/ZShell/Services/usagefmt.cpp @@ -0,0 +1,33 @@ +#include "usagefmt.hpp" + +namespace { + +constexpr qreal kKib = 1024.0; +constexpr qreal kMib = kKib * 1024.0; +constexpr qreal kGib = kMib * 1024.0; + +bool finitePositive(qreal v) { + return std::isfinite(v) && v >= 0.0; +} + +} // namespace + +namespace ZShell::services::usagefmt { + +FormatResult UsageFmt::formatKib(qreal kib, qreal total) const { + if (!finitePositive(kib) || !finitePositive(total)) { + return { 0.0, 0.0, "KiB" }; + } + if (total >= kGib) { + return { kib / kGib, total / kGib, "TiB" }; + } + if (total >= kMib) { + return { kib / kMib, total / kMib, "GiB" }; + } + if (total >= kKib) { + return { kib / kKib, total / kKib, "MiB" }; + } + return { kib, total, "KiB" }; +} + +} // namespace ZShell::services::usagefmt diff --git a/Plugins/ZShell/Services/usagefmt.hpp b/Plugins/ZShell/Services/usagefmt.hpp new file mode 100644 index 0000000..97d38b7 --- /dev/null +++ b/Plugins/ZShell/Services/usagefmt.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace ZShell::services::usagefmt { + +struct FormatResult { + Q_GADGET + QML_ANONYMOUS + + Q_PROPERTY(qreal value MEMBER value CONSTANT) + Q_PROPERTY(qreal total MEMBER total CONSTANT) + Q_PROPERTY(QString unit MEMBER unit CONSTANT) + +public: + qreal value; + qreal total; + QString unit; +}; + +class UsageFmt : public QObject { +Q_OBJECT +QML_ELEMENT +QML_SINGLETON + +public: +Q_INVOKABLE [[nodiscard]] FormatResult formatKib(qreal kib, qreal total) const; +}; + +} // namespace ZShell::services::usagefmt