From 86003d8eaa768548e0392e7dd146453fc1ab5c2e Mon Sep 17 00:00:00 2001 From: Damocles Date: Tue, 14 Apr 2026 00:55:44 +0200 Subject: [PATCH] disk: add hover panel with per-mount usage bars --- modules/Disk.qml | 177 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 164 insertions(+), 13 deletions(-) diff --git a/modules/Disk.qml b/modules/Disk.qml index 065f507..45f145f 100644 --- a/modules/Disk.qml +++ b/modules/Disk.qml @@ -5,26 +5,42 @@ import "." as M M.BarSection { id: root spacing: Math.max(1, M.Theme.moduleSpacing - 2) - tooltip: root.freePct + "% free of " + root.totalTb.toFixed(1) + " TB" + tooltip: "" - property int freePct: 0 - property real totalTb: 0 + property var _mounts: [] + property int _rootPct: 0 Process { id: proc running: true - command: ["sh", "-c", "df -B1 --output=size,avail / | tail -1"] + command: ["sh", "-c", "df -x tmpfs -x devtmpfs -x squashfs -x efivarfs -x overlay -B1 --output=target,size,used 2>/dev/null | awk 'NR>1 && $2+0>0 {print $1\"|\"$2\"|\"$3}'"] stdout: StdioCollector { onStreamFinished: { - const parts = text.trim().split(/\s+/).map(Number); - const size = parts[0], avail = parts[1]; - if (size > 0) { - root.freePct = Math.round((avail / size) * 100); - root.totalTb = size / 1e12; + const lines = text.trim().split("\n").filter(l => l); + const mounts = []; + for (const line of lines) { + const parts = line.split("|"); + if (parts.length < 3) + continue; + const total = parseInt(parts[1]); + const used = parseInt(parts[2]); + if (total <= 0) + continue; + mounts.push({ + "target": parts[0], + "pct": Math.round(used / total * 100), + "usedBytes": used, + "totalBytes": total + }); } + root._mounts = mounts; + const rm = mounts.find(m => m.target === "/"); + if (rm) + root._rootPct = rm.pct; } } } + Timer { interval: M.Modules.disk.interval || 30000 running: true @@ -32,15 +48,150 @@ M.BarSection { onTriggered: proc.running = true } + function _fmt(bytes) { + if (bytes >= 1e12) + return (bytes / 1e12).toFixed(1) + "T"; + if (bytes >= 1e9) + return Math.round(bytes / 1e9) + "G"; + if (bytes >= 1e6) + return Math.round(bytes / 1e6) + "M"; + return bytes + "B"; + } + + function _barColor(pct) { + const t = Math.max(0, Math.min(100, pct)) / 100; + const a = t < 0.5 ? M.Theme.base0B : M.Theme.base0A; + const b = t < 0.5 ? M.Theme.base0A : M.Theme.base08; + const u = t < 0.5 ? t * 2 : (t - 0.5) * 2; + return Qt.rgba(a.r + (b.r - a.r) * u, a.g + (b.g - a.g) * u, a.b + (b.b - a.b) * u, 1); + } + + property bool _pinned: false + readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered + readonly property bool _showPanel: _anyHover || _pinned + + on_AnyHoverChanged: { + if (_anyHover) + _unpinTimer.stop(); + else if (_pinned) + _unpinTimer.start(); + } + + Timer { + id: _unpinTimer + interval: 500 + onTriggered: root._pinned = false + } + M.BarIcon { icon: "\uF0C9" - anchors.verticalCenter: parent.verticalCenter + TapHandler { + cursorShape: Qt.PointingHandCursor + onTapped: root._pinned = !root._pinned + } } M.BarLabel { - label: root.freePct + "% " + root.totalTb.toFixed(1) - minText: "100% 9.9" - + label: root._rootPct + "%" + minText: "100%" anchors.verticalCenter: parent.verticalCenter + TapHandler { + cursorShape: Qt.PointingHandCursor + onTapped: root._pinned = !root._pinned + } + } + + M.HoverPanel { + id: hoverPanel + showPanel: root._showPanel + screen: QsWindow.window?.screen ?? null + anchorItem: root + accentColor: root.accentColor + panelNamespace: "nova-disk" + contentWidth: 260 + + Item { + width: parent.width + height: 28 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "Disk" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + } + } + + Repeater { + model: root._mounts + + delegate: Item { + required property var modelData + width: hoverPanel.contentWidth + height: 22 + + Text { + id: mountLabel + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: modelData.target + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + elide: Text.ElideRight + width: 72 + } + + Item { + id: mountBar + anchors.left: mountLabel.right + anchors.leftMargin: 6 + anchors.right: sizeLabel.left + anchors.rightMargin: 6 + anchors.verticalCenter: parent.verticalCenter + height: 4 + + Rectangle { + anchors.fill: parent + color: M.Theme.base02 + radius: 2 + } + + Rectangle { + width: parent.width * (modelData.pct / 100) + height: parent.height + color: root._barColor(modelData.pct) + radius: 2 + Behavior on width { + NumberAnimation { + duration: 200 + } + } + } + } + + Text { + id: sizeLabel + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: root._fmt(modelData.usedBytes) + "/" + root._fmt(modelData.totalBytes) + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + width: 72 + horizontalAlignment: Text.AlignRight + } + } + } + + Item { + width: 1 + height: 4 + } } }