summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorThanh Minh <112760114+tmih06@users.noreply.github.com>2026-02-19 18:53:22 +0700
committerGitHub <noreply@github.com>2026-02-19 22:53:22 +1100
commit46174d1934370b2f4a7da43a3dbc0289c14a5a2d (patch)
tree2f401649de42e204f9904ed7797a3600e4654b57
parentfeat: add wallpaperEnabled option (#1187) (diff)
downloadcaelestia-shell-46174d1934370b2f4a7da43a3dbc0289c14a5a2d.tar.gz
caelestia-shell-46174d1934370b2f4a7da43a3dbc0289c14a5a2d.tar.bz2
caelestia-shell-46174d1934370b2f4a7da43a3dbc0289c14a5a2d.zip
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>
-rw-r--r--.gitignore1
-rw-r--r--config/Config.qml10
-rw-r--r--config/DashboardConfig.qml11
-rw-r--r--modules/controlcenter/PaneRegistry.qml6
-rw-r--r--modules/controlcenter/Panes.qml1
-rw-r--r--modules/controlcenter/components/ConnectedButtonGroup.qml (renamed from modules/controlcenter/taskbar/ConnectedButtonGroup.qml)0
-rw-r--r--modules/controlcenter/components/ReadonlySlider.qml67
-rw-r--r--modules/controlcenter/dashboard/DashboardPane.qml123
-rw-r--r--modules/controlcenter/dashboard/GeneralSection.qml81
-rw-r--r--modules/controlcenter/dashboard/PerformanceSection.qml85
-rw-r--r--modules/dashboard/Media.qml24
-rw-r--r--modules/dashboard/Performance.qml1026
-rw-r--r--modules/dashboard/Tabs.qml4
-rw-r--r--modules/dashboard/dash/Media.qml2
-rw-r--r--services/NetworkUsage.qml233
-rw-r--r--services/SystemUsage.qml165
16 files changed, 1656 insertions, 183 deletions
diff --git a/.gitignore b/.gitignore
index a114b1b..c30a6d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
/.qmlls.ini
build/
.cache/
+logs \ No newline at end of file
diff --git a/config/Config.qml b/config/Config.qml
index 1ec47c1..1c01719 100644
--- a/config/Config.qml
+++ b/config/Config.qml
@@ -258,8 +258,16 @@ Singleton {
return {
enabled: dashboard.enabled,
showOnHover: dashboard.showOnHover,
- mediaUpdateInterval: dashboard.mediaUpdateInterval,
+ updateInterval: dashboard.updateInterval,
dragThreshold: dashboard.dragThreshold,
+ performance: {
+ showBattery: dashboard.performance.showBattery,
+ showGpu: dashboard.performance.showGpu,
+ showCpu: dashboard.performance.showCpu,
+ showMemory: dashboard.performance.showMemory,
+ showStorage: dashboard.performance.showStorage,
+ showNetwork: dashboard.performance.showNetwork
+ },
sizes: {
tabIndicatorHeight: dashboard.sizes.tabIndicatorHeight,
tabIndicatorSpacing: dashboard.sizes.tabIndicatorSpacing,
diff --git a/config/DashboardConfig.qml b/config/DashboardConfig.qml
index 030292b..e089550 100644
--- a/config/DashboardConfig.qml
+++ b/config/DashboardConfig.qml
@@ -4,8 +4,19 @@ JsonObject {
property bool enabled: true
property bool showOnHover: true
property int mediaUpdateInterval: 500
+ property int resourceUpdateInterval: 1000
property int dragThreshold: 50
property Sizes sizes: Sizes {}
+ property Performance performance: Performance {}
+
+ component Performance: JsonObject {
+ property bool showBattery: true
+ property bool showGpu: true
+ property bool showCpu: true
+ property bool showMemory: true
+ property bool showStorage: true
+ property bool showNetwork: true
+ }
component Sizes: JsonObject {
readonly property int tabIndicatorHeight: 3
diff --git a/modules/controlcenter/PaneRegistry.qml b/modules/controlcenter/PaneRegistry.qml
index c2a0f38..ca48551 100644
--- a/modules/controlcenter/PaneRegistry.qml
+++ b/modules/controlcenter/PaneRegistry.qml
@@ -41,6 +41,12 @@ QtObject {
readonly property string label: "launcher"
readonly property string icon: "apps"
readonly property string component: "launcher/LauncherPane.qml"
+ },
+ QtObject {
+ readonly property string id: "dashboard"
+ readonly property string label: "dashboard"
+ readonly property string icon: "dashboard"
+ readonly property string component: "dashboard/DashboardPane.qml"
}
]
diff --git a/modules/controlcenter/Panes.qml b/modules/controlcenter/Panes.qml
index 4a4460c..ab2f808 100644
--- a/modules/controlcenter/Panes.qml
+++ b/modules/controlcenter/Panes.qml
@@ -6,6 +6,7 @@ import "audio"
import "appearance"
import "taskbar"
import "launcher"
+import "dashboard"
import qs.components
import qs.services
import qs.config
diff --git a/modules/controlcenter/taskbar/ConnectedButtonGroup.qml b/modules/controlcenter/components/ConnectedButtonGroup.qml
index 01cd612..01cd612 100644
--- a/modules/controlcenter/taskbar/ConnectedButtonGroup.qml
+++ b/modules/controlcenter/components/ConnectedButtonGroup.qml
diff --git a/modules/controlcenter/components/ReadonlySlider.qml b/modules/controlcenter/components/ReadonlySlider.qml
new file mode 100644
index 0000000..169d636
--- /dev/null
+++ b/modules/controlcenter/components/ReadonlySlider.qml
@@ -0,0 +1,67 @@
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+ColumnLayout {
+ id: root
+
+ property string label: ""
+ property real value: 0
+ property real from: 0
+ property real to: 100
+ property string suffix: ""
+ property bool readonly: false
+
+ spacing: Appearance.spacing.small
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+
+ StyledText {
+ visible: root.label !== ""
+ text: root.label
+ font.pointSize: Appearance.font.size.normal
+ color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface
+ }
+
+ Item {
+ Layout.fillWidth: true
+ }
+
+ MaterialIcon {
+ visible: root.readonly
+ text: "lock"
+ color: Colours.palette.m3outline
+ font.pointSize: Appearance.font.size.small
+ }
+
+ StyledText {
+ text: Math.round(root.value) + (root.suffix !== "" ? " " + root.suffix : "")
+ font.pointSize: Appearance.font.size.normal
+ color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3onSurface
+ }
+ }
+
+ StyledRect {
+ Layout.fillWidth: true
+ implicitHeight: Appearance.padding.normal
+ radius: Appearance.rounding.full
+ color: Colours.layer(Colours.palette.m3surfaceContainerHighest, 1)
+ opacity: root.readonly ? 0.5 : 1.0
+
+ StyledRect {
+ anchors.left: parent.left
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ width: parent.width * ((root.value - root.from) / (root.to - root.from))
+ radius: parent.radius
+ color: root.readonly ? Colours.palette.m3outline : Colours.palette.m3primary
+ }
+ }
+}
diff --git a/modules/controlcenter/dashboard/DashboardPane.qml b/modules/controlcenter/dashboard/DashboardPane.qml
new file mode 100644
index 0000000..72e3e6e
--- /dev/null
+++ b/modules/controlcenter/dashboard/DashboardPane.qml
@@ -0,0 +1,123 @@
+pragma ComponentBehavior: Bound
+
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.components.effects
+import qs.components.containers
+import qs.services
+import qs.config
+import qs.utils
+import Quickshell
+import Quickshell.Widgets
+import QtQuick
+import QtQuick.Layouts
+
+Item {
+ id: root
+
+ required property Session session
+
+ // General Settings
+ property bool enabled: Config.dashboard.enabled ?? true
+ property bool showOnHover: Config.dashboard.showOnHover ?? true
+ property int updateInterval: Config.dashboard.updateInterval ?? 1000
+ property int dragThreshold: Config.dashboard.dragThreshold ?? 50
+
+ // Performance Resources
+ property bool showBattery: Config.dashboard.performance.showBattery ?? false
+ property bool showGpu: Config.dashboard.performance.showGpu ?? true
+ property bool showCpu: Config.dashboard.performance.showCpu ?? true
+ property bool showMemory: Config.dashboard.performance.showMemory ?? true
+ property bool showStorage: Config.dashboard.performance.showStorage ?? true
+ property bool showNetwork: Config.dashboard.performance.showNetwork ?? true
+
+ anchors.fill: parent
+
+ function saveConfig() {
+ Config.dashboard.enabled = root.enabled;
+ Config.dashboard.showOnHover = root.showOnHover;
+ Config.dashboard.updateInterval = root.updateInterval;
+ Config.dashboard.dragThreshold = root.dragThreshold;
+ Config.dashboard.performance.showBattery = root.showBattery;
+ Config.dashboard.performance.showGpu = root.showGpu;
+ Config.dashboard.performance.showCpu = root.showCpu;
+ Config.dashboard.performance.showMemory = root.showMemory;
+ Config.dashboard.performance.showStorage = root.showStorage;
+ Config.dashboard.performance.showNetwork = root.showNetwork;
+ // Note: sizes properties are readonly and cannot be modified
+ Config.save();
+ }
+
+ ClippingRectangle {
+ id: dashboardClippingRect
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.normal
+ anchors.leftMargin: 0
+ anchors.rightMargin: Appearance.padding.normal
+
+ radius: dashboardBorder.innerRadius
+ color: "transparent"
+
+ Loader {
+ id: dashboardLoader
+
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large + Appearance.padding.normal
+ anchors.leftMargin: Appearance.padding.large
+ anchors.rightMargin: Appearance.padding.large
+
+ sourceComponent: dashboardContentComponent
+ }
+ }
+
+ InnerBorder {
+ id: dashboardBorder
+ leftThickness: 0
+ rightThickness: Appearance.padding.normal
+ }
+
+ Component {
+ id: dashboardContentComponent
+
+ StyledFlickable {
+ id: dashboardFlickable
+ flickableDirection: Flickable.VerticalFlick
+ contentHeight: dashboardLayout.height
+
+ StyledScrollBar.vertical: StyledScrollBar {
+ flickable: dashboardFlickable
+ }
+
+ ColumnLayout {
+ id: dashboardLayout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+
+ spacing: Appearance.spacing.normal
+
+ RowLayout {
+ spacing: Appearance.spacing.smaller
+
+ StyledText {
+ text: qsTr("Dashboard")
+ font.pointSize: Appearance.font.size.large
+ font.weight: 500
+ }
+ }
+
+ // General Settings Section
+ GeneralSection {
+ rootItem: root
+ }
+
+ // Performance Resources Section
+ PerformanceSection {
+ rootItem: root
+ }
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/dashboard/GeneralSection.qml b/modules/controlcenter/dashboard/GeneralSection.qml
new file mode 100644
index 0000000..bf54e97
--- /dev/null
+++ b/modules/controlcenter/dashboard/GeneralSection.qml
@@ -0,0 +1,81 @@
+import ".."
+import "../components"
+import qs.components
+import qs.components.controls
+import qs.services
+import qs.config
+import QtQuick
+import QtQuick.Layouts
+
+SectionContainer {
+ id: root
+
+ required property var rootItem
+
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("General Settings")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ SwitchRow {
+ label: qsTr("Enabled")
+ checked: root.rootItem.enabled
+ onToggled: checked => {
+ root.rootItem.enabled = checked;
+ root.rootItem.saveConfig();
+ }
+ }
+
+ SwitchRow {
+ label: qsTr("Show on hover")
+ checked: root.rootItem.showOnHover
+ onToggled: checked => {
+ root.rootItem.showOnHover = checked;
+ root.rootItem.saveConfig();
+ }
+ }
+
+ SectionContainer {
+ contentSpacing: Appearance.spacing.normal
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Update interval")
+ value: root.rootItem.updateInterval
+ from: 100
+ to: 10000
+ stepSize: 100
+ suffix: "ms"
+ validator: IntValidator { bottom: 100; top: 10000 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ root.rootItem.updateInterval = Math.round(newValue);
+ root.rootItem.saveConfig();
+ }
+ }
+
+ SliderInput {
+ Layout.fillWidth: true
+
+ label: qsTr("Drag threshold")
+ value: root.rootItem.dragThreshold
+ from: 0
+ to: 100
+ suffix: "px"
+ validator: IntValidator { bottom: 0; top: 100 }
+ formatValueFunction: (val) => Math.round(val).toString()
+ parseValueFunction: (text) => parseInt(text)
+
+ onValueModified: (newValue) => {
+ root.rootItem.dragThreshold = Math.round(newValue);
+ root.rootItem.saveConfig();
+ }
+ }
+ }
+}
diff --git a/modules/controlcenter/dashboard/PerformanceSection.qml b/modules/controlcenter/dashboard/PerformanceSection.qml
new file mode 100644
index 0000000..7e72782
--- /dev/null
+++ b/modules/controlcenter/dashboard/PerformanceSection.qml
@@ -0,0 +1,85 @@
+import ".."
+import "../components"
+import QtQuick
+import QtQuick.Layouts
+import Quickshell.Services.UPower
+import qs.components
+import qs.components.controls
+import qs.config
+import qs.services
+
+SectionContainer {
+ id: root
+
+ required property var rootItem
+ // GPU toggle is hidden when gpuType is "NONE" (no GPU data available)
+ readonly property bool gpuAvailable: SystemUsage.gpuType !== "NONE"
+ // Battery toggle is hidden when no laptop battery is present
+ readonly property bool batteryAvailable: UPower.displayDevice.isLaptopBattery
+
+ Layout.fillWidth: true
+ alignTop: true
+
+ StyledText {
+ text: qsTr("Performance Resources")
+ font.pointSize: Appearance.font.size.normal
+ }
+
+ ConnectedButtonGroup {
+ rootItem: root.rootItem
+ options: {
+ let opts = [];
+ if (root.batteryAvailable)
+ opts.push({
+ "label": qsTr("Battery"),
+ "propertyName": "showBattery",
+ "onToggled": function(checked) {
+ root.rootItem.showBattery = checked;
+ root.rootItem.saveConfig();
+ }
+ });
+
+ if (root.gpuAvailable)
+ opts.push({
+ "label": qsTr("GPU"),
+ "propertyName": "showGpu",
+ "onToggled": function(checked) {
+ root.rootItem.showGpu = checked;
+ root.rootItem.saveConfig();
+ }
+ });
+
+ opts.push({
+ "label": qsTr("CPU"),
+ "propertyName": "showCpu",
+ "onToggled": function(checked) {
+ root.rootItem.showCpu = checked;
+ root.rootItem.saveConfig();
+ }
+ }, {
+ "label": qsTr("Memory"),
+ "propertyName": "showMemory",
+ "onToggled": function(checked) {
+ root.rootItem.showMemory = checked;
+ root.rootItem.saveConfig();
+ }
+ }, {
+ "label": qsTr("Storage"),
+ "propertyName": "showStorage",
+ "onToggled": function(checked) {
+ root.rootItem.showStorage = checked;
+ root.rootItem.saveConfig();
+ }
+ }, {
+ "label": qsTr("Network"),
+ "propertyName": "showNetwork",
+ "onToggled": function(checked) {
+ root.rootItem.showNetwork = checked;
+ root.rootItem.saveConfig();
+ }
+ });
+ return opts;
+ }
+ }
+
+}
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
+
+ Ref {
+ service: SystemUsage
+ }
+
+ ColumnLayout {
+ id: mainColumn
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
- primary: true
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.normal
+ visible: Config.dashboard.performance.showCpu || (Config.dashboard.performance.showGpu && SystemUsage.gpuType !== "NONE")
- value1: Math.min(1, SystemUsage.cpuTemp / 90)
- value2: SystemUsage.cpuPerc
+ 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
+ }
- label1: root.displayTemp(SystemUsage.cpuTemp)
- label2: `${Math.round(SystemUsage.cpuPerc * 100)}%`
+ 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
+ }
+ }
+
+ 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
+ }
- sublabel1: qsTr("CPU temp")
- sublabel2: qsTr("Usage")
+ 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
+ }
+ }
+ }
+
+ 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
- value1: SystemUsage.memPerc
- value2: SystemUsage.storagePerc
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ Component.onCompleted: animatedPercentage = percentage
+ onPercentageChanged: animatedPercentage = percentage
- label1: {
- const fmt = SystemUsage.formatKib(SystemUsage.memUsed);
- return `${+fmt.value.toFixed(1)}${fmt.unit}`;
+ // 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)
}
- label2: {
- const fmt = SystemUsage.formatKib(SystemUsage.storageUsed);
- return `${Math.floor(fmt.value)}${fmt.unit}`;
+
+ 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
+ }
+ }
+
+ 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
+
+ Layout.fillWidth: true
+ spacing: Appearance.spacing.small
- required property real value1
- required property real value2
- required property string sublabel1
- required property string sublabel2
- required property string label1
- required property string label2
+ MaterialIcon {
+ text: parent.icon
+ fill: 1
+ color: parent.accentColor
+ font.pointSize: Appearance.spacing.large
+ }
- property bool primary
- readonly property real primaryMult: primary ? 1.2 : 1
+ StyledText {
+ Layout.fillWidth: true
+ text: parent.title
+ font.pointSize: Appearance.font.size.normal
+ elide: Text.ElideRight
+ }
+ }
- readonly property real thickness: Config.dashboard.sizes.resourceProgessThickness * primaryMult
+ component ProgressBar: StyledRect {
+ id: progressBar
- property color fg1: Colours.palette.m3primary
- property color fg2: Colours.palette.m3secondary
- property color bg1: Colours.palette.m3primaryContainer
- property color bg2: Colours.palette.m3secondaryContainer
+ property real value: 0
+ property color fgColor: Colours.palette.m3primary
+ property color bgColor: Colours.layer(Colours.palette.m3surfaceContainerHigh, 2)
+ property real animatedValue: 0
- implicitWidth: Config.dashboard.sizes.resourceSize * primaryMult
- implicitHeight: Config.dashboard.sizes.resourceSize * primaryMult
+ color: bgColor
+ radius: Appearance.rounding.full
+ Component.onCompleted: animatedValue = value
+ onValueChanged: animatedValue = value
- onValue1Changed: canvas.requestPaint()
- onValue2Changed: canvas.requestPaint()
- onFg1Changed: canvas.requestPaint()
- onFg2Changed: canvas.requestPaint()
- onBg1Changed: canvas.requestPaint()
- onBg2Changed: canvas.requestPaint()
+ 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
+ }
- Column {
- anchors.centerIn: parent
+ Behavior on animatedValue {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+ }
- StyledText {
- anchors.horizontalCenter: parent.horizontalCenter
+ 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
- text: res.label1
- font.pointSize: Appearance.font.size.extraLarge * res.primaryMult
+ 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)
+ }
+
+ 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
}
- StyledText {
- anchors.horizontalCenter: parent.horizontalCenter
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: Appearance.spacing.normal
- text: res.sublabel1
- color: Colours.palette.m3onSurfaceVariant
- font.pointSize: Appearance.font.size.smaller * res.primaryMult
+ 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.horizontalCenter: parent.right
- anchors.top: parent.verticalCenter
- anchors.horizontalCenterOffset: -res.thickness / 2
- anchors.topMargin: res.thickness / 2 + Appearance.spacing.small
+ 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.label2
- font.pointSize: Appearance.font.size.smaller * 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.sublabel2
- color: Colours.palette.m3onSurfaceVariant
- font.pointSize: Appearance.font.size.small * res.primaryMult
+ Behavior on animatedUsage {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
+ }
+
+ Behavior on animatedTemp {
+ Anim {
+ duration: Appearance.anim.durations.large
}
}
+ }
+
+ component GaugeCard: StyledRect {
+ id: gaugeCard
- Canvas {
- id: canvas
+ 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
- readonly property real centerX: width / 2
- readonly property real centerY: height / 2
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ clip: true
+ Component.onCompleted: animatedPercentage = percentage
+ onPercentageChanged: animatedPercentage = percentage
- readonly property real arc1Start: degToRad(45)
- readonly property real arc1End: degToRad(220)
- readonly property real arc2Start: degToRad(230)
- readonly property real arc2End: degToRad(360)
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.smaller
- function degToRad(deg: int): real {
- return deg * Math.PI / 180;
+ CardHeader {
+ icon: gaugeCard.icon
+ title: gaugeCard.title
+ accentColor: gaugeCard.accentColor
}
- anchors.fill: parent
+ Item {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+
+ Canvas {
+ id: gaugeCanvas
- onPaint: {
- const ctx = getContext("2d");
- ctx.reset();
+ 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()
- ctx.lineWidth = res.thickness;
- ctx.lineCap = Appearance.rounding.scale === 0 ? "square" : "round";
+ Connections {
+ function onAnimatedPercentageChanged() {
+ gaugeCanvas.requestPaint();
+ }
- 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;
+ target: gaugeCard
+ }
- ctx.beginPath();
- ctx.arc(cx, cy, radius, a1s, a1e, false);
- ctx.strokeStyle = res.bg1;
- ctx.stroke();
+ Connections {
+ function onPaletteChanged() {
+ gaugeCanvas.requestPaint();
+ }
- ctx.beginPath();
- ctx.arc(cx, cy, radius, a1s, (a1e - a1s) * res.value1 + a1s, false);
- ctx.strokeStyle = res.fg1;
- ctx.stroke();
+ target: Colours
+ }
+ }
- ctx.beginPath();
- ctx.arc(cx, cy, radius, a2s, a2e, false);
- ctx.strokeStyle = res.bg2;
- ctx.stroke();
+ StyledText {
+ anchors.centerIn: parent
+ text: `${Math.round(gaugeCard.percentage * 100)}%`
+ font.pointSize: Appearance.font.size.extraLarge
+ font.weight: Font.Medium
+ color: gaugeCard.accentColor
+ }
+ }
+
+ StyledText {
+ Layout.alignment: Qt.AlignHCenter
+ text: gaugeCard.subtitle
+ font.pointSize: Appearance.font.size.smaller
+ color: Colours.palette.m3onSurfaceVariant
+ }
+ }
- ctx.beginPath();
- ctx.arc(cx, cy, radius, a2s, (a2e - a2s) * res.value2 + a2s, false);
- ctx.strokeStyle = res.fg2;
- ctx.stroke();
+ Behavior on animatedPercentage {
+ Anim {
+ duration: Appearance.anim.durations.large
}
}
+ }
+
+ 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
- Behavior on value1 {
- Anim {}
+ 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;
+ }
+
+ // Update diskCount and animatedPercentage when disks data changes
+ Connections {
+ function onDisksChanged() {
+ if (SystemUsage.disks.length !== storageGaugeCard.diskCount)
+ storageGaugeCard.diskCount = SystemUsage.disks.length;
- Behavior on value2 {
- Anim {}
+ // Update animated percentage when disk data refreshes
+ if (storageGaugeCard.currentDisk)
+ storageGaugeCard.animatedPercentage = storageGaugeCard.currentDisk.perc;
+ }
+
+ target: SystemUsage
}
- Behavior on fg1 {
- CAnim {}
+ 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;
+ }
}
- Behavior on fg2 {
- CAnim {}
+ 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
+ }
+ }
+ }
+
+ 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
+ }
+ }
+
+ 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
+ }
}
- Behavior on bg1 {
- CAnim {}
+ Behavior on animatedPercentage {
+ Anim {
+ duration: Appearance.anim.durations.large
+ }
}
+ }
+
+ component NetworkCard: StyledRect {
+ id: networkCard
+
+ property color accentColor: Colours.palette.m3primary
- Behavior on bg2 {
- CAnim {}
+ color: Colours.tPalette.m3surfaceContainer
+ radius: Appearance.rounding.large
+ clip: true
+
+ Ref {
+ service: NetworkUsage
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: Appearance.padding.large
+ spacing: Appearance.spacing.small
+
+ CardHeader {
+ icon: "swap_vert"
+ title: qsTr("Network")
+ accentColor: networkCard.accentColor
+ }
+
+ // 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
+ }
+ }
+
+ // 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
+ }
+ }
+
+ // 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
diff --git a/services/NetworkUsage.qml b/services/NetworkUsage.qml
new file mode 100644
index 0000000..502ec3a
--- /dev/null
+++ b/services/NetworkUsage.qml
@@ -0,0 +1,233 @@
+pragma Singleton
+
+import qs.config
+
+import Quickshell
+import Quickshell.Io
+
+import QtQuick
+
+Singleton {
+ id: root
+
+ property int refCount: 0
+
+ // Current speeds in bytes per second
+ readonly property real downloadSpeed: _downloadSpeed
+ readonly property real uploadSpeed: _uploadSpeed
+
+ // Total bytes transferred since tracking started
+ readonly property real downloadTotal: _downloadTotal
+ readonly property real uploadTotal: _uploadTotal
+
+ // History of speeds for sparkline (most recent at end)
+ readonly property var downloadHistory: _downloadHistory
+ readonly property var uploadHistory: _uploadHistory
+ readonly property int historyLength: 30
+
+ // Private properties
+ property real _downloadSpeed: 0
+ property real _uploadSpeed: 0
+ property real _downloadTotal: 0
+ property real _uploadTotal: 0
+ property var _downloadHistory: []
+ property var _uploadHistory: []
+
+ // Previous readings for calculating speed
+ property real _prevRxBytes: 0
+ property real _prevTxBytes: 0
+ property real _prevTimestamp: 0
+
+ // Initial readings for calculating totals
+ property real _initialRxBytes: 0
+ property real _initialTxBytes: 0
+ property bool _initialized: false
+
+ function formatBytes(bytes: real): var {
+ // Handle negative or invalid values
+ if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) {
+ return {
+ value: 0,
+ unit: "B/s"
+ };
+ }
+
+ if (bytes < 1024) {
+ return {
+ value: bytes,
+ unit: "B/s"
+ };
+ } else if (bytes < 1024 * 1024) {
+ return {
+ value: bytes / 1024,
+ unit: "KB/s"
+ };
+ } else if (bytes < 1024 * 1024 * 1024) {
+ return {
+ value: bytes / (1024 * 1024),
+ unit: "MB/s"
+ };
+ } else {
+ return {
+ value: bytes / (1024 * 1024 * 1024),
+ unit: "GB/s"
+ };
+ }
+ }
+
+ function formatBytesTotal(bytes: real): var {
+ // Handle negative or invalid values
+ if (bytes < 0 || isNaN(bytes) || !isFinite(bytes)) {
+ return {
+ value: 0,
+ unit: "B"
+ };
+ }
+
+ if (bytes < 1024) {
+ return {
+ value: bytes,
+ unit: "B"
+ };
+ } else if (bytes < 1024 * 1024) {
+ return {
+ value: bytes / 1024,
+ unit: "KB"
+ };
+ } else if (bytes < 1024 * 1024 * 1024) {
+ return {
+ value: bytes / (1024 * 1024),
+ unit: "MB"
+ };
+ } else {
+ return {
+ value: bytes / (1024 * 1024 * 1024),
+ unit: "GB"
+ };
+ }
+ }
+
+ function parseNetDev(content: string): var {
+ const lines = content.split("\n");
+ let totalRx = 0;
+ let totalTx = 0;
+
+ for (let i = 2; i < lines.length; i++) {
+ const line = lines[i].trim();
+ if (!line)
+ continue;
+
+ const parts = line.split(/\s+/);
+ if (parts.length < 10)
+ continue;
+
+ const iface = parts[0].replace(":", "");
+ // Skip loopback interface
+ if (iface === "lo")
+ continue;
+
+ const rxBytes = parseFloat(parts[1]) || 0;
+ const txBytes = parseFloat(parts[9]) || 0;
+
+ totalRx += rxBytes;
+ totalTx += txBytes;
+ }
+
+ return {
+ rx: totalRx,
+ tx: totalTx
+ };
+ }
+
+ FileView {
+ id: netDevFile
+ path: "/proc/net/dev"
+ }
+
+ Timer {
+ interval: Config.dashboard.resourceUpdateInterval
+ running: root.refCount > 0
+ repeat: true
+ triggeredOnStart: true
+
+ onTriggered: {
+ netDevFile.reload();
+ const content = netDevFile.text();
+ if (!content)
+ return;
+
+ const data = root.parseNetDev(content);
+ const now = Date.now();
+
+ if (!root._initialized) {
+ root._initialRxBytes = data.rx;
+ root._initialTxBytes = data.tx;
+ root._prevRxBytes = data.rx;
+ root._prevTxBytes = data.tx;
+ root._prevTimestamp = now;
+ root._initialized = true;
+ return;
+ }
+
+ const timeDelta = (now - root._prevTimestamp) / 1000; // seconds
+ if (timeDelta > 0) {
+ // Calculate byte deltas
+ let rxDelta = data.rx - root._prevRxBytes;
+ let txDelta = data.tx - root._prevTxBytes;
+
+ // Handle counter overflow (when counters wrap around from max to 0)
+ // This happens when counters exceed 32-bit or 64-bit limits
+ if (rxDelta < 0) {
+ // Counter wrapped around - assume 64-bit counter
+ rxDelta += Math.pow(2, 64);
+ }
+ if (txDelta < 0) {
+ txDelta += Math.pow(2, 64);
+ }
+
+ // Calculate speeds
+ root._downloadSpeed = rxDelta / timeDelta;
+ root._uploadSpeed = txDelta / timeDelta;
+
+ const maxHistory = root.historyLength + 1;
+
+ if (root._downloadSpeed >= 0 && isFinite(root._downloadSpeed)) {
+ let newDownHist = root._downloadHistory.slice();
+ newDownHist.push(root._downloadSpeed);
+ if (newDownHist.length > maxHistory) {
+ newDownHist.shift();
+ }
+ root._downloadHistory = newDownHist;
+ }
+
+ if (root._uploadSpeed >= 0 && isFinite(root._uploadSpeed)) {
+ let newUpHist = root._uploadHistory.slice();
+ newUpHist.push(root._uploadSpeed);
+ if (newUpHist.length > maxHistory) {
+ newUpHist.shift();
+ }
+ root._uploadHistory = newUpHist;
+ }
+ }
+
+ // Calculate totals with overflow handling
+ let downTotal = data.rx - root._initialRxBytes;
+ let upTotal = data.tx - root._initialTxBytes;
+
+ // Handle counter overflow for totals
+ if (downTotal < 0) {
+ downTotal += Math.pow(2, 64);
+ }
+ if (upTotal < 0) {
+ upTotal += Math.pow(2, 64);
+ }
+
+ root._downloadTotal = downTotal;
+ root._uploadTotal = upTotal;
+
+ root._prevRxBytes = data.rx;
+ root._prevTxBytes = data.tx;
+ root._prevTimestamp = now;
+ }
+ }
+}
diff --git a/services/SystemUsage.qml b/services/SystemUsage.qml
index bd02da3..1144932 100644
--- a/services/SystemUsage.qml
+++ b/services/SystemUsage.qml
@@ -8,24 +8,50 @@ import QtQuick
Singleton {
id: root
+ // CPU properties
+ property string cpuName: ""
property real cpuPerc
property real cpuTemp
+
+ // GPU properties
readonly property string gpuType: Config.services.gpuType.toUpperCase() || autoGpuType
property string autoGpuType: "NONE"
+ property string gpuName: ""
property real gpuPerc
property real gpuTemp
+
+ // Memory properties
property real memUsed
property real memTotal
readonly property real memPerc: memTotal > 0 ? memUsed / memTotal : 0
- property real storageUsed
- property real storageTotal
- property real storagePerc: storageTotal > 0 ? storageUsed / storageTotal : 0
+
+ // Storage properties (aggregated)
+ 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;
+ }
+
+ // Individual disks: Array of { mount, used, total, free, perc }
+ property var disks: []
property real lastCpuIdle
property real lastCpuTotal
property int refCount
+ function cleanCpuName(name: string): string {
+ return name.replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/CPU/gi, "").replace(/\d+th Gen /gi, "").replace(/\d+nd Gen /gi, "").replace(/\d+rd Gen /gi, "").replace(/\d+st Gen /gi, "").replace(/Core /gi, "").replace(/Processor/gi, "").replace(/\s+/g, " ").trim();
+ }
+
+ function cleanGpuName(name: string): string {
+ return name.replace(/NVIDIA GeForce /gi, "").replace(/NVIDIA /gi, "").replace(/AMD Radeon /gi, "").replace(/AMD /gi, "").replace(/Intel /gi, "").replace(/\(R\)/gi, "").replace(/\(TM\)/gi, "").replace(/Graphics/gi, "").replace(/\s+/g, " ").trim();
+ }
+
function formatKib(kib: real): var {
const mib = 1024;
const gib = 1024 ** 2;
@@ -54,7 +80,7 @@ Singleton {
Timer {
running: root.refCount > 0
- interval: 3000
+ interval: Config.dashboard.resourceUpdateInterval
repeat: true
triggeredOnStart: true
onTriggered: {
@@ -66,6 +92,18 @@ Singleton {
}
}
+ // One-time CPU info detection (name)
+ 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
@@ -101,41 +139,120 @@ Singleton {
Process {
id: storage
- command: ["sh", "-c", "df | grep '^/dev/' | awk '{print $1, $3, $4}'"]
+ // Get physical disks with aggregated usage from their partitions
+ // lsblk outputs: NAME SIZE TYPE FSUSED FSSIZE in bytes
+ command: ["lsblk", "-b", "-o", "NAME,SIZE,TYPE,FSUSED,FSSIZE", "-P"]
stdout: StdioCollector {
onStreamFinished: {
- const deviceMap = new Map();
+ const diskMap = {}; // Map disk name -> { name, totalSize, used, fsTotal }
+ const lines = text.trim().split("\n");
- for (const line of text.trim().split("\n")) {
+ for (const line of lines) {
if (line.trim() === "")
continue;
- const parts = line.trim().split(/\s+/);
- if (parts.length >= 3) {
- const device = parts[0];
- const used = parseInt(parts[1], 10) || 0;
- const avail = parseInt(parts[2], 10) || 0;
+ // Parse KEY="VALUE" format
+ 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;
- // Only keep the entry with the largest total space for each device
- if (!deviceMap.has(device) || (used + avail) > (deviceMap.get(device).used + deviceMap.get(device).avail)) {
- deviceMap.set(device, {
- used: used,
- avail: avail
- });
+ // 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;
}
}
}
+ // Convert map to sorted array
+ const diskList = [];
let totalUsed = 0;
- let totalAvail = 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;
- for (const [device, stats] of deviceMap) {
- totalUsed += stats.used;
- totalAvail += stats.avail;
+ // Convert bytes to KiB for consistency with formatKib
+ diskList.push({
+ mount: disk.name // Using 'mount' property for compatibility
+ ,
+ used: used / 1024,
+ total: total / 1024,
+ free: (total - used) / 1024,
+ perc: perc
+ });
+
+ totalUsed += used;
+ totalSize += total;
}
- root.storageUsed = totalUsed;
- root.storageTotal = totalUsed + totalAvail;
+ root.disks = diskList;
+ }
+ }
+ }
+
+ // GPU name detection (one-time)
+ Process {
+ id: gpuNameDetect
+
+ running: true
+ command: ["sh", "-c", "nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null || lspci 2>/dev/null | grep -i 'vga\\|3d\\|display' | head -1"]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ const output = text.trim();
+ if (!output)
+ return;
+
+ // Check if it's from nvidia-smi (clean GPU name)
+ if (output.toLowerCase().includes("nvidia") || output.toLowerCase().includes("geforce") || output.toLowerCase().includes("rtx") || output.toLowerCase().includes("gtx")) {
+ root.gpuName = root.cleanGpuName(output);
+ } else {
+ // Parse lspci output: extract name from brackets or after colon
+ const bracketMatch = output.match(/\[([^\]]+)\]/);
+ if (bracketMatch) {
+ root.gpuName = root.cleanGpuName(bracketMatch[1]);
+ } else {
+ const colonMatch = output.match(/:\s*(.+)/);
+ if (colonMatch)
+ root.gpuName = root.cleanGpuName(colonMatch[1]);
+ }
+ }
}
}
}