From 46174d1934370b2f4a7da43a3dbc0289c14a5a2d Mon Sep 17 00:00:00 2001 From: Thanh Minh <112760114+tmih06@users.noreply.github.com> Date: Thu, 19 Feb 2026 18:53:22 +0700 Subject: dashboard/performance: new design, configurable, controlcenter support (#975) * feat(dashboard): add configurable performance resources - Add config options to show/hide Battery, GPU, CPU, Memory, Storage - Make dashboard responsive based on number of visible resources - Scale resource sizes and spacing dynamically for 3, 4, or 5 items - Battery shows charge status and time remaining/to full - Each resource can be individually toggled via config * fix(dashboard): add dynamic right margin for last visible resource Ensures the rightmost resource always has proper margin to prevent content from being cut off at the edge * fix(performance): comment out duplicated value2 properties for memory and storage resources * controlcenter: add settings for dashboard * feat: handle readonly properties and re-usable codes * Feature/performance tab rework (#5) * dashboard/performance: rework tab with card-based grid layout - Replace circular arc meters with card-based grid layout - CPU/GPU cards show hardware name, usage and temperature with horizontal bars - Memory card with 3/4 arc indicator and used/total at bottom - Storage card shows physical disks from lsblk with aggregated partition usage - Add cpuName, gpuName, cpuFreq, cpuMaxFreq, disks properties to SystemUsage - Clean hardware names (remove Intel/AMD/NVIDIA prefixes, TM/R symbols) * dashboard/performance: new hero card design * dashboard/performance: update storage indicators to be reponsive to the physical disks count * dashboard/performance: fix the overlay bounding issue * dashboard/perfromance: refactor code * dashboard/performance: add battery gauge * dashboard/performance: correct battery icon * dashboard/performance: configurable battery * dashboard/performance: update layout * dashboard/performance: move the "Usage" text on top and smaller the font size * dashboard/performance: add a lot of configurations * dashboard/performance: add network metrics * fix: issue with hot reload * chore: update default vaule for mainValueSpacing to 0 * chore: group settings into collapasible sections * chore: making GPU & Battery toggle not showing if not found * chore: fix network widget spacing & text * chore: remove old disk bars configs, add update interval * chore: remove old & unused value, functions * chore: network graph update smoothly when data points change * chore: refactor settings - de-flood settings, most of the font & size setting now follow the global Appearance config - Most of sliders are not needed anymore, only keep the update interval slider - clean up * chore: remove readonly properties from the controlcenter/dashboard. * chore: minor fix * fix: fix warning about onPercChange() * fix: network metrics negative number * fix: add minimal height & width, placeholder for none toggled * fix: network graph move smoothly (#6) * fix: network graph move smoothly * clean up * fix: graph animation even more smooth * fix: padding issue * chore: network icons short description * fix --------- Co-authored-by: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> --- modules/dashboard/Media.qml | 24 +- modules/dashboard/Performance.qml | 1036 +++++++++++++++++++++++++++++++------ modules/dashboard/Tabs.qml | 4 +- modules/dashboard/dash/Media.qml | 2 +- 4 files changed, 903 insertions(+), 163 deletions(-) (limited to 'modules/dashboard') diff --git a/modules/dashboard/Media.qml b/modules/dashboard/Media.qml index ce5db35..722bc93 100644 --- a/modules/dashboard/Media.qml +++ b/modules/dashboard/Media.qml @@ -1,14 +1,12 @@ pragma ComponentBehavior: Bound import qs.components -import qs.components.effects import qs.components.controls import qs.services import qs.utils import qs.config import Caelestia.Services import Quickshell -import Quickshell.Widgets import Quickshell.Services.Mpris import QtQuick import QtQuick.Layouts @@ -142,7 +140,7 @@ Item { anchors.fill: parent - source: Players.active?.trackArtUrl ?? "" + source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type asynchronous: true fillMode: Image.PreserveAspectCrop sourceSize.width: width @@ -324,7 +322,7 @@ Item { disabled: !Players.list.length active: menuItems.find(m => m.modelData === Players.active) ?? menuItems[0] ?? null - menu.onItemSelected: item => Players.manualActive = item.modelData + menu.onItemSelected: item => Players.manualActive = (item as PlayerItem).modelData menuItems: playerList.instances fallbackIcon: "music_off" @@ -341,13 +339,7 @@ Item { model: Players.list - MenuItem { - required property MprisPlayer modelData - - icon: modelData === Players.active ? "check" : "" - text: Players.getIdentity(modelData) - activeIcon: "animated_images" - } + PlayerItem {} } } @@ -380,13 +372,21 @@ Item { height: visualiser.height * 0.75 playing: Players.active?.isPlaying ?? false - speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment + speed: Audio.beatTracker.bpm / Appearance.anim.mediaGifSpeedAdjustment // qmllint disable unresolved-type source: Paths.absolutePath(Config.paths.mediaGif) asynchronous: true fillMode: AnimatedImage.PreserveAspectFit } } + component PlayerItem: MenuItem { + required property MprisPlayer modelData + + icon: modelData === Players.active ? "check" : "" + text: Players.getIdentity(modelData) + activeIcon: "animated_images" + } + component PlayerControl: IconButton { Layout.preferredWidth: implicitWidth + (stateLayer.pressed ? Appearance.padding.large : internalChecked ? Appearance.padding.smaller : 0) radius: stateLayer.pressed ? Appearance.rounding.small / 2 : internalChecked ? Appearance.rounding.small : implicitHeight / 2 diff --git a/modules/dashboard/Performance.qml b/modules/dashboard/Performance.qml index 5e00d89..a4e24c4 100644 --- a/modules/dashboard/Performance.qml +++ b/modules/dashboard/Performance.qml @@ -1,227 +1,967 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Services.UPower import qs.components import qs.components.misc -import qs.services import qs.config -import QtQuick -import QtQuick.Layouts +import qs.services -RowLayout { +Item { id: root - readonly property int padding: Appearance.padding.large + readonly property int minWidth: 400 + 400 + Appearance.spacing.normal + 120 + Appearance.padding.large * 2 function displayTemp(temp: real): string { return `${Math.ceil(Config.services.useFahrenheit ? temp * 1.8 + 32 : temp)}°${Config.services.useFahrenheit ? "F" : "C"}`; } - spacing: Appearance.spacing.large * 3 + implicitWidth: Math.max(minWidth, content.implicitWidth) + implicitHeight: placeholder.visible ? placeholder.height : content.implicitHeight - Ref { - service: SystemUsage - } + StyledRect { + id: placeholder + + anchors.centerIn: parent + width: 400 + height: 350 + radius: Appearance.rounding.large + color: Colours.tPalette.m3surfaceContainer + visible: !Config.dashboard.performance.showCpu && !(Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") && !Config.dashboard.performance.showMemory && !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork && !(UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery) - Resource { - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: root.padding - Layout.bottomMargin: root.padding - Layout.leftMargin: root.padding * 2 + ColumnLayout { + anchors.centerIn: parent + spacing: Appearance.spacing.normal - value1: Math.min(1, SystemUsage.gpuTemp / 90) - value2: SystemUsage.gpuPerc + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "tune" + font.pointSize: Appearance.font.size.extraLarge * 2 + color: Colours.palette.m3onSurfaceVariant + } - label1: root.displayTemp(SystemUsage.gpuTemp) - label2: `${Math.round(SystemUsage.gpuPerc * 100)}%` + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("No widgets enabled") + font.pointSize: Appearance.font.size.large + color: Colours.palette.m3onSurface + } - sublabel1: qsTr("GPU temp") - sublabel2: qsTr("Usage") + StyledText { + Layout.alignment: Qt.AlignHCenter + text: qsTr("Enable widgets in dashboard settings") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } } - Resource { - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: root.padding - Layout.bottomMargin: root.padding + RowLayout { + id: content + + anchors.left: parent.left + anchors.right: parent.right + spacing: Appearance.spacing.normal + visible: !placeholder.visible - primary: true + Ref { + service: SystemUsage + } - value1: Math.min(1, SystemUsage.cpuTemp / 90) - value2: SystemUsage.cpuPerc + ColumnLayout { + id: mainColumn + + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE") + + HeroCard { + Layout.fillWidth: true + Layout.minimumWidth: 400 + Layout.preferredHeight: 150 + visible: Config.dashboard.performance.showCpu + icon: "memory" + title: SystemUsage.cpuName ? `CPU - ${SystemUsage.cpuName}` : qsTr("CPU") + mainValue: `${Math.round(SystemUsage.cpuPerc * 100)}%` + mainLabel: qsTr("Usage") + secondaryValue: root.displayTemp(SystemUsage.cpuTemp) + secondaryLabel: qsTr("Temp") + usage: SystemUsage.cpuPerc + temperature: SystemUsage.cpuTemp + accentColor: Colours.palette.m3primary + } + + HeroCard { + Layout.fillWidth: true + Layout.minimumWidth: 400 + Layout.preferredHeight: 150 + visible: Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE" + icon: "desktop_windows" + title: SystemUsage.gpuName ? `GPU - ${SystemUsage.gpuName}` : qsTr("GPU") + mainValue: `${Math.round(SystemUsage.gpuPerc * 100)}%` + mainLabel: qsTr("Usage") + secondaryValue: root.displayTemp(SystemUsage.gpuTemp) + secondaryLabel: qsTr("Temp") + usage: SystemUsage.gpuPerc + temperature: SystemUsage.gpuTemp + accentColor: Colours.palette.m3secondary + } + } - label1: root.displayTemp(SystemUsage.cpuTemp) - label2: `${Math.round(SystemUsage.cpuPerc * 100)}%` + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + visible: Config.dashboard.performance.showMemory || Config.dashboard.performance.showStorage || Config.dashboard.performance.showNetwork + + GaugeCard { + Layout.minimumWidth: 250 + Layout.preferredHeight: 220 + Layout.fillWidth: !Config.dashboard.performance.showStorage && !Config.dashboard.performance.showNetwork + icon: "memory_alt" + title: qsTr("Memory") + 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}`; + } + accentColor: Colours.palette.m3tertiary + visible: Config.dashboard.performance.showMemory + } + + StorageGaugeCard { + Layout.minimumWidth: 250 + Layout.preferredHeight: 220 + Layout.fillWidth: !Config.dashboard.performance.showNetwork + visible: Config.dashboard.performance.showStorage + } + + NetworkCard { + Layout.fillWidth: true + Layout.minimumWidth: 200 + Layout.preferredHeight: 220 + visible: Config.dashboard.performance.showNetwork + } + } + } - sublabel1: qsTr("CPU temp") - sublabel2: qsTr("Usage") + BatteryTank { + Layout.preferredWidth: 120 + Layout.preferredHeight: mainColumn.implicitHeight + visible: UPower.displayDevice.isLaptopBattery && Config.dashboard.performance.showBattery + } } - Resource { - Layout.alignment: Qt.AlignVCenter - Layout.topMargin: root.padding - Layout.bottomMargin: root.padding - Layout.rightMargin: root.padding * 3 + component BatteryTank: StyledClippingRect { + id: batteryTank + + property real percentage: UPower.displayDevice.percentage + property bool isCharging: UPower.displayDevice.state === UPowerDeviceState.Charging + property color accentColor: Colours.palette.m3primary + property real animatedPercentage: 0 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + Component.onCompleted: animatedPercentage = percentage + onPercentageChanged: animatedPercentage = percentage + + // Background Fill + StyledRect { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: parent.height * batteryTank.animatedPercentage + color: Qt.alpha(batteryTank.accentColor, 0.15) + } - value1: SystemUsage.memPerc - value2: SystemUsage.storagePerc + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small + + // Header Section + ColumnLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.small + + MaterialIcon { + text: { + if (!UPower.displayDevice.isLaptopBattery) { + if (PowerProfiles.profile === PowerProfile.PowerSaver) + return "energy_savings_leaf"; + + if (PowerProfiles.profile === PowerProfile.Performance) + return "rocket_launch"; + + return "balance"; + } + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return "battery_full"; + + const perc = UPower.displayDevice.percentage; + const charging = [UPowerDeviceState.Charging, UPowerDeviceState.PendingCharge].includes(UPower.displayDevice.state); + if (perc >= 0.99) + return "battery_full"; + + let level = Math.floor(perc * 7); + if (charging && (level === 4 || level === 1)) + level--; + + return charging ? `battery_charging_${(level + 3) * 10}` : `battery_${level}_bar`; + } + font.pointSize: Appearance.font.size.large + color: batteryTank.accentColor + } + + StyledText { + Layout.fillWidth: true + text: qsTr("Battery") + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3onSurface + } + } - label1: { - const fmt = SystemUsage.formatKib(SystemUsage.memUsed); - return `${+fmt.value.toFixed(1)}${fmt.unit}`; - } - label2: { - const fmt = SystemUsage.formatKib(SystemUsage.storageUsed); - return `${Math.floor(fmt.value)}${fmt.unit}`; + Item { + Layout.fillHeight: true + } + + // Bottom Info Section + ColumnLayout { + Layout.fillWidth: true + spacing: -4 + + StyledText { + Layout.alignment: Qt.AlignRight + text: `${Math.round(batteryTank.percentage * 100)}%` + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: batteryTank.accentColor + } + + StyledText { + Layout.alignment: Qt.AlignRight + text: { + if (UPower.displayDevice.state === UPowerDeviceState.FullyCharged) + return qsTr("Full"); + + if (batteryTank.isCharging) + return qsTr("Charging"); + + const s = UPower.displayDevice.timeToEmpty; + if (s === 0) + return qsTr("..."); + + const hr = Math.floor(s / 3600); + const min = Math.floor((s % 3600) / 60); + if (hr > 0) + return `${hr}h ${min}m`; + + return `${min}m`; + } + font.pointSize: Appearance.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + } } - sublabel1: qsTr("Memory") - sublabel2: qsTr("Storage") + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } } - component Resource: Item { - id: res + component CardHeader: RowLayout { + property string icon + property string title + property color accentColor: Colours.palette.m3primary - required property real value1 - required property real value2 - required property string sublabel1 - required property string sublabel2 - required property string label1 - required property string label2 + Layout.fillWidth: true + spacing: Appearance.spacing.small - property bool primary - readonly property real primaryMult: primary ? 1.2 : 1 + MaterialIcon { + text: parent.icon + fill: 1 + color: parent.accentColor + font.pointSize: Appearance.spacing.large + } - readonly property real thickness: Config.dashboard.sizes.resourceProgessThickness * primaryMult + StyledText { + Layout.fillWidth: true + text: parent.title + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + } - property color fg1: Colours.palette.m3primary - property color fg2: Colours.palette.m3secondary - property color bg1: Colours.palette.m3primaryContainer - property color bg2: Colours.palette.m3secondaryContainer + component ProgressBar: StyledRect { + id: progressBar + + property real value: 0 + property color fgColor: Colours.palette.m3primary + property color bgColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + property real animatedValue: 0 + + color: bgColor + radius: Appearance.rounding.full + Component.onCompleted: animatedValue = value + onValueChanged: animatedValue = value + + StyledRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * progressBar.animatedValue + color: progressBar.fgColor + radius: Appearance.rounding.full + } - implicitWidth: Config.dashboard.sizes.resourceSize * primaryMult - implicitHeight: Config.dashboard.sizes.resourceSize * primaryMult + Behavior on animatedValue { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + component HeroCard: StyledClippingRect { + id: heroCard + + property string icon + property string title + property string mainValue + property string mainLabel + property string secondaryValue + property string secondaryLabel + property real usage: 0 + property real temperature: 0 + property color accentColor: Colours.palette.m3primary + readonly property real maxTemp: 100 + readonly property real tempProgress: Math.min(1, Math.max(0, temperature / maxTemp)) + property real animatedUsage: 0 + property real animatedTemp: 0 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + Component.onCompleted: { + animatedUsage = usage; + animatedTemp = tempProgress; + } + onUsageChanged: animatedUsage = usage + onTempProgressChanged: animatedTemp = tempProgress + + StyledRect { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * heroCard.animatedUsage + color: Qt.alpha(heroCard.accentColor, 0.15) + } - onValue1Changed: canvas.requestPaint() - onValue2Changed: canvas.requestPaint() - onFg1Changed: canvas.requestPaint() - onFg2Changed: canvas.requestPaint() - onBg1Changed: canvas.requestPaint() - onBg2Changed: canvas.requestPaint() + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: Appearance.padding.large + anchors.rightMargin: Appearance.padding.large + anchors.topMargin: Appearance.padding.normal + anchors.bottomMargin: Appearance.padding.normal + spacing: Appearance.spacing.small + + CardHeader { + icon: heroCard.icon + title: heroCard.title + accentColor: heroCard.accentColor + } + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Appearance.spacing.normal + + Column { + Layout.alignment: Qt.AlignBottom + Layout.fillWidth: true + spacing: Appearance.spacing.small + + Row { + spacing: Appearance.spacing.small + + StyledText { + text: heroCard.secondaryValue + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + } + + StyledText { + text: heroCard.secondaryLabel + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + anchors.baseline: parent.children[0].baseline + } + } + + ProgressBar { + width: parent.width * 0.5 + height: 6 + value: heroCard.tempProgress + fgColor: heroCard.accentColor + bgColor: Qt.alpha(heroCard.accentColor, 0.2) + } + } + + Item { + Layout.fillWidth: true + } + } + } Column { - anchors.centerIn: parent + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Appearance.padding.large + anchors.rightMargin: 32 + spacing: 0 StyledText { - anchors.horizontalCenter: parent.horizontalCenter - - text: res.label1 - font.pointSize: Appearance.font.size.extraLarge * res.primaryMult + anchors.right: parent.right + text: heroCard.mainLabel + font.pointSize: Appearance.font.size.normal + color: Colours.palette.m3onSurfaceVariant } StyledText { - anchors.horizontalCenter: parent.horizontalCenter + anchors.right: parent.right + text: heroCard.mainValue + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: heroCard.accentColor + } + } - text: res.sublabel1 - color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.smaller * res.primaryMult + Behavior on animatedUsage { + Anim { + duration: Appearance.anim.durations.large } } - Column { - anchors.horizontalCenter: parent.right - anchors.top: parent.verticalCenter - anchors.horizontalCenterOffset: -res.thickness / 2 - anchors.topMargin: res.thickness / 2 + Appearance.spacing.small + Behavior on animatedTemp { + Anim { + duration: Appearance.anim.durations.large + } + } + } - StyledText { - anchors.horizontalCenter: parent.horizontalCenter + component GaugeCard: StyledRect { + id: gaugeCard + + property string icon + property string title + property real percentage: 0 + property string subtitle + property color accentColor: Colours.palette.m3primary + readonly property real arcStartAngle: 0.75 * Math.PI + readonly property real arcSweep: 1.5 * Math.PI + property real animatedPercentage: 0 + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + clip: true + Component.onCompleted: animatedPercentage = percentage + onPercentageChanged: animatedPercentage = percentage + + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller - text: res.label2 - font.pointSize: Appearance.font.size.smaller * res.primaryMult + CardHeader { + icon: gaugeCard.icon + title: gaugeCard.title + accentColor: gaugeCard.accentColor } - StyledText { - anchors.horizontalCenter: parent.horizontalCenter + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Canvas { + id: gaugeCanvas + + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const cx = width / 2; + const cy = height / 2; + const radius = (Math.min(width, height) - 12) / 2; + const lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); + ctx.stroke(); + if (gaugeCard.animatedPercentage > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, radius, gaugeCard.arcStartAngle, gaugeCard.arcStartAngle + gaugeCard.arcSweep * gaugeCard.animatedPercentage); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = gaugeCard.accentColor; + ctx.stroke(); + } + } + Component.onCompleted: requestPaint() + + Connections { + function onAnimatedPercentageChanged() { + gaugeCanvas.requestPaint(); + } + + target: gaugeCard + } + + Connections { + function onPaletteChanged() { + gaugeCanvas.requestPaint(); + } + + target: Colours + } + } + + StyledText { + anchors.centerIn: parent + text: `${Math.round(gaugeCard.percentage * 100)}%` + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: gaugeCard.accentColor + } + } - text: res.sublabel2 + StyledText { + Layout.alignment: Qt.AlignHCenter + text: gaugeCard.subtitle + font.pointSize: Appearance.font.size.smaller color: Colours.palette.m3onSurfaceVariant - font.pointSize: Appearance.font.size.small * res.primaryMult } } - Canvas { - id: canvas + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + } - readonly property real centerX: width / 2 - readonly property real centerY: height / 2 + component StorageGaugeCard: StyledRect { + id: storageGaugeCard + + property int currentDiskIndex: 0 + readonly property var currentDisk: SystemUsage.disks.length > 0 ? SystemUsage.disks[currentDiskIndex] : null + property int diskCount: 0 + readonly property real arcStartAngle: 0.75 * Math.PI + readonly property real arcSweep: 1.5 * Math.PI + property real animatedPercentage: 0 + property color accentColor: Colours.palette.m3secondary + + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + clip: true + Component.onCompleted: { + diskCount = SystemUsage.disks.length; + if (currentDisk) + animatedPercentage = currentDisk.perc; + } + onCurrentDiskChanged: { + if (currentDisk) + animatedPercentage = currentDisk.perc; + } - readonly property real arc1Start: degToRad(45) - readonly property real arc1End: degToRad(220) - readonly property real arc2Start: degToRad(230) - readonly property real arc2End: degToRad(360) + // Update diskCount and animatedPercentage when disks data changes + Connections { + function onDisksChanged() { + if (SystemUsage.disks.length !== storageGaugeCard.diskCount) + storageGaugeCard.diskCount = SystemUsage.disks.length; - function degToRad(deg: int): real { - return deg * Math.PI / 180; + // 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; + } + } - onPaint: { - const ctx = getContext("2d"); - ctx.reset(); + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.smaller + + CardHeader { + icon: "hard_disk" + title: { + const base = qsTr("Storage"); + if (!storageGaugeCard.currentDisk) + return base; + + return `${base} - ${storageGaugeCard.currentDisk.mount}`; + } + accentColor: storageGaugeCard.accentColor + + // Scroll hint icon + MaterialIcon { + text: "unfold_more" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + visible: storageGaugeCard.diskCount > 1 + opacity: 0.7 + ToolTip.visible: hintHover.hovered + ToolTip.text: qsTr("Scroll to switch disks") + ToolTip.delay: 500 + + HoverHandler { + id: hintHover + } + } + } - ctx.lineWidth = res.thickness; - ctx.lineCap = Appearance.rounding.scale === 0 ? "square" : "round"; + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Canvas { + id: storageGaugeCanvas + + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const cx = width / 2; + const cy = height / 2; + const radius = (Math.min(width, height) - 12) / 2; + const lineWidth = 10; + ctx.beginPath(); + ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = Colours.layer(Colours.palette.m3surfaceContainerHigh, 2); + ctx.stroke(); + if (storageGaugeCard.animatedPercentage > 0) { + ctx.beginPath(); + ctx.arc(cx, cy, radius, storageGaugeCard.arcStartAngle, storageGaugeCard.arcStartAngle + storageGaugeCard.arcSweep * storageGaugeCard.animatedPercentage); + ctx.lineWidth = lineWidth; + ctx.lineCap = "round"; + ctx.strokeStyle = storageGaugeCard.accentColor; + ctx.stroke(); + } + } + Component.onCompleted: requestPaint() + + Connections { + function onAnimatedPercentageChanged() { + storageGaugeCanvas.requestPaint(); + } + + target: storageGaugeCard + } + + Connections { + function onPaletteChanged() { + storageGaugeCanvas.requestPaint(); + } + + target: Colours + } + } + + StyledText { + anchors.centerIn: parent + text: storageGaugeCard.currentDisk ? `${Math.round(storageGaugeCard.currentDisk.perc * 100)}%` : "—" + font.pointSize: Appearance.font.size.extraLarge + font.weight: Font.Medium + color: storageGaugeCard.accentColor + } + } - const radius = (Math.min(width, height) - ctx.lineWidth) / 2; - const cx = centerX; - const cy = centerY; - const a1s = arc1Start; - const a1e = arc1End; - const a2s = arc2Start; - const a2e = arc2End; + StyledText { + Layout.alignment: Qt.AlignHCenter + 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}`; + } + font.pointSize: Appearance.font.size.smaller + color: Colours.palette.m3onSurfaceVariant + } + } - ctx.beginPath(); - ctx.arc(cx, cy, radius, a1s, a1e, false); - ctx.strokeStyle = res.bg1; - ctx.stroke(); + Behavior on animatedPercentage { + Anim { + duration: Appearance.anim.durations.large + } + } + } - ctx.beginPath(); - ctx.arc(cx, cy, radius, a1s, (a1e - a1s) * res.value1 + a1s, false); - ctx.strokeStyle = res.fg1; - ctx.stroke(); + component NetworkCard: StyledRect { + id: networkCard - ctx.beginPath(); - ctx.arc(cx, cy, radius, a2s, a2e, false); - ctx.strokeStyle = res.bg2; - ctx.stroke(); + property color accentColor: Colours.palette.m3primary - ctx.beginPath(); - ctx.arc(cx, cy, radius, a2s, (a2e - a2s) * res.value2 + a2s, false); - ctx.strokeStyle = res.fg2; - ctx.stroke(); - } - } + color: Colours.tPalette.m3surfaceContainer + radius: Appearance.rounding.large + clip: true - Behavior on value1 { - Anim {} + Ref { + service: NetworkUsage } - Behavior on value2 { - Anim {} - } + ColumnLayout { + anchors.fill: parent + anchors.margins: Appearance.padding.large + spacing: Appearance.spacing.small - Behavior on fg1 { - CAnim {} - } + CardHeader { + icon: "swap_vert" + title: qsTr("Network") + accentColor: networkCard.accentColor + } - Behavior on fg2 { - CAnim {} - } + // Sparkline graph + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Canvas { + id: sparklineCanvas + + property var downHistory: NetworkUsage.downloadHistory + property var upHistory: NetworkUsage.uploadHistory + property real targetMax: 1024 + property real smoothMax: targetMax + property real slideProgress: 0 + property int _tickCount: 0 + property int _lastTickCount: -1 + + function checkAndAnimate(): void { + const currentLength = (downHistory || []).length; + if (currentLength > 0 && _tickCount !== _lastTickCount) { + _lastTickCount = _tickCount; + updateMax(); + } + } + + function updateMax(): void { + const downHist = downHistory || []; + const upHist = upHistory || []; + const allValues = downHist.concat(upHist); + targetMax = Math.max(...allValues, 1024); + requestPaint(); + } + + anchors.fill: parent + onDownHistoryChanged: checkAndAnimate() + onUpHistoryChanged: checkAndAnimate() + onSmoothMaxChanged: requestPaint() + onSlideProgressChanged: requestPaint() + + onPaint: { + const ctx = getContext("2d"); + ctx.reset(); + const w = width; + const h = height; + const downHist = downHistory || []; + const upHist = upHistory || []; + if (downHist.length < 2 && upHist.length < 2) + return; + + const maxVal = smoothMax; + + const drawLine = (history, color, fillAlpha) => { + if (history.length < 2) + return; + + const len = history.length; + const stepX = w / (NetworkUsage.historyLength - 1); + const startX = w - (len - 1) * stepX - stepX * slideProgress + stepX; + ctx.beginPath(); + ctx.moveTo(startX, h - (history[0] / maxVal) * h); + for (let i = 1; i < len; i++) { + const x = startX + i * stepX; + const y = h - (history[i] / maxVal) * h; + ctx.lineTo(x, y); + } + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.stroke(); + ctx.lineTo(startX + (len - 1) * stepX, h); + ctx.lineTo(startX, h); + ctx.closePath(); + ctx.fillStyle = Qt.rgba(Qt.color(color).r, Qt.color(color).g, Qt.color(color).b, fillAlpha); + ctx.fill(); + }; + + drawLine(upHist, Colours.palette.m3secondary.toString(), 0.15); + drawLine(downHist, Colours.palette.m3tertiary.toString(), 0.2); + } + + Component.onCompleted: updateMax() + + Connections { + function onPaletteChanged() { + sparklineCanvas.requestPaint(); + } + + target: Colours + } + + Timer { + interval: Config.dashboard.resourceUpdateInterval + running: true + repeat: true + onTriggered: sparklineCanvas._tickCount++ + } + + NumberAnimation on slideProgress { + from: 0 + to: 1 + duration: Config.dashboard.resourceUpdateInterval + loops: Animation.Infinite + running: true + } + + Behavior on smoothMax { + Anim { + duration: Appearance.anim.durations.large + } + } + } + + // "No data" placeholder + StyledText { + anchors.centerIn: parent + text: qsTr("Collecting data...") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + visible: NetworkUsage.downloadHistory.length < 2 + opacity: 0.6 + } + } - Behavior on bg1 { - CAnim {} - } + // Download row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "download" + color: Colours.palette.m3tertiary + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: qsTr("Download") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.downloadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3tertiary + } + } - Behavior on bg2 { - CAnim {} + // Upload row + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "upload" + color: Colours.palette.m3secondary + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: qsTr("Upload") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + Item { + Layout.fillWidth: true + } + + StyledText { + text: { + const fmt = NetworkUsage.formatBytes(NetworkUsage.uploadSpeed ?? 0); + return fmt ? `${fmt.value.toFixed(1)} ${fmt.unit}` : "0.0 B/s"; + } + font.pointSize: Appearance.font.size.normal + font.weight: Font.Medium + color: Colours.palette.m3secondary + } + } + + // Session totals + RowLayout { + Layout.fillWidth: true + spacing: Appearance.spacing.normal + + MaterialIcon { + text: "history" + color: Colours.palette.m3onSurfaceVariant + font.pointSize: Appearance.font.size.normal + } + + StyledText { + text: qsTr("Total") + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + + Item { + Layout.fillWidth: true + } + + StyledText { + 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"; + } + font.pointSize: Appearance.font.size.small + color: Colours.palette.m3onSurfaceVariant + } + } } } } diff --git a/modules/dashboard/Tabs.qml b/modules/dashboard/Tabs.qml index 98ea880..1d50d26 100644 --- a/modules/dashboard/Tabs.qml +++ b/modules/dashboard/Tabs.qml @@ -60,10 +60,10 @@ Item { id: indicator anchors.top: bar.bottom - anchors.topMargin: Config.dashboard.sizes.tabIndicatorSpacing + anchors.topMargin: 5 implicitWidth: bar.currentItem.implicitWidth - implicitHeight: Config.dashboard.sizes.tabIndicatorHeight + implicitHeight: 3 x: { const tab = bar.currentItem; diff --git a/modules/dashboard/dash/Media.qml b/modules/dashboard/dash/Media.qml index ad87335..d650669 100644 --- a/modules/dashboard/dash/Media.qml +++ b/modules/dashboard/dash/Media.qml @@ -106,7 +106,7 @@ Item { anchors.fill: parent - source: Players.active?.trackArtUrl ?? "" + source: Players.active?.trackArtUrl ?? "" // qmllint disable incompatible-type asynchronous: true fillMode: Image.PreserveAspectCrop sourceSize.width: width -- cgit v1.2.3-freya