diff --git a/shell/applets/BatteryApplet.qml b/shell/applets/BatteryApplet.qml new file mode 100644 index 0000000..be30b8f --- /dev/null +++ b/shell/applets/BatteryApplet.qml @@ -0,0 +1,276 @@ +import QtQuick +import "../services" as S + +Column { + id: root + + required property color accentColor + + property bool active: true + + readonly property color _stateColor: S.BatteryService.charging ? S.Theme.base0B : S.BatteryService.critical ? S.Theme.base09 : S.BatteryService.percent < S.BatteryService.warnThresh ? S.Theme.base0A : root.accentColor + + // Header - pct + time + Item { + width: parent.width + height: 28 + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: { + const t = S.BatteryService.charging ? S.BatteryService.timeToFull : S.BatteryService.timeToEmpty; + const ts = S.BatteryService.fmtTime(t); + return Math.round(S.BatteryService.percent) + "%" + (ts ? " " + ts : ""); + } + color: root._stateColor + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.fontFamily + font.bold: true + } + } + + // Progress bar + Item { + width: parent.width + height: 14 + + Item { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + height: 6 + + Rectangle { + anchors.fill: parent + color: S.Theme.base02 + radius: 3 + } + + Rectangle { + width: parent.width * Math.min(1, S.BatteryService.percent / 100) + height: parent.height + color: root._stateColor + radius: 3 + Behavior on width { + enabled: root.active + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + } + + // Warning threshold marker + Rectangle { + x: parent.width * (S.BatteryService.warnThresh / 100) - 1 + width: 1 + height: parent.height + 4 + anchors.verticalCenter: parent.verticalCenter + color: S.Theme.base0A + opacity: 0.6 + } + + // Critical threshold marker + Rectangle { + x: parent.width * (S.BatteryService.critThresh / 100) - 1 + width: 1 + height: parent.height + 4 + anchors.verticalCenter: parent.verticalCenter + color: S.Theme.base08 + opacity: 0.6 + } + } + } + + // 24h history sparkline (area chart) + Canvas { + id: _sparkline + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + height: 44 + + property var _hist: S.BatteryService.history + property color _col: root._stateColor + + on_HistChanged: if (root.active) + requestPaint() + on_ColChanged: if (root.active) + requestPaint() + + onVisibleChanged: if (visible) + requestPaint() + + onPaint: { + const ctx = getContext("2d"); + if (!ctx) + return; + ctx.clearRect(0, 0, width, height); + const d = _hist; + if (d.length < 2) + return; + + const maxSamples = 1440; + const xScale = width / maxSamples; + const xOffset = (maxSamples - d.length) * xScale; + + // Background tint + ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.07).toString(); + ctx.fillRect(0, 0, width, height); + + // Warning threshold line + const warnY = height - height * (S.BatteryService.warnThresh / 100); + ctx.strokeStyle = S.Theme.base0A.toString(); + ctx.globalAlpha = 0.3; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(0, warnY); + ctx.lineTo(width, warnY); + ctx.stroke(); + + // Critical threshold line + const critY = height - height * (S.BatteryService.critThresh / 100); + ctx.strokeStyle = S.Theme.base08.toString(); + ctx.beginPath(); + ctx.moveTo(0, critY); + ctx.lineTo(width, critY); + ctx.stroke(); + + ctx.setLineDash([]); + ctx.globalAlpha = 1.0; + + // Filled area under the curve + ctx.beginPath(); + ctx.moveTo(xOffset, height); + for (let i = 0; i < d.length; i++) { + const x = xOffset + i * xScale; + const y = height - height * (d[i] / 100); + ctx.lineTo(x, y); + } + ctx.lineTo(xOffset + (d.length - 1) * xScale, height); + ctx.closePath(); + ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.18).toString(); + ctx.fill(); + + // Top line + ctx.beginPath(); + for (let i = 0; i < d.length; i++) { + const x = xOffset + i * xScale; + const y = height - height * (d[i] / 100); + if (i === 0) + ctx.moveTo(x, y); + else + ctx.lineTo(x, y); + } + ctx.strokeStyle = _col.toString(); + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + + // Footer: thresholds + time label + Item { + width: parent.width + height: 16 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "warn " + S.BatteryService.warnThresh + "% crit " + S.BatteryService.critThresh + "%" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 0.5 + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "24h" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + } + } + + // Separator + Rectangle { + width: parent.width - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: S.Theme.base03 + } + + // Rate row + Item { + width: parent.width + height: 20 + visible: S.BatteryService.changeRate !== 0 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: S.BatteryService.charging ? "Charging" : "Discharging" + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: { + const r = Math.abs(S.BatteryService.changeRate); + return r > 0 ? r.toFixed(1) + " W" : ""; + } + color: root._stateColor + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + + // Health row + Item { + width: parent.width + height: 20 + visible: S.BatteryService.healthSupported + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "Health" + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: Math.round(S.BatteryService.healthPercent) + "%" + color: { + const h = S.BatteryService.healthPercent; + return h < 50 ? S.Theme.base08 : h < 75 ? S.Theme.base0A : S.Theme.base0B; + } + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + + Item { + width: 1 + height: 4 + } +} diff --git a/shell/applets/GpuApplet.qml b/shell/applets/GpuApplet.qml new file mode 100644 index 0000000..7b2272f --- /dev/null +++ b/shell/applets/GpuApplet.qml @@ -0,0 +1,223 @@ +import QtQuick +import "../services" as S + +Column { + id: root + + required property color accentColor + + property bool active: true + + function _loadColor(pct) { + const t = Math.max(0, Math.min(100, pct)) / 100; + const a = t < 0.5 ? S.Theme.base0B : S.Theme.base0A; + const b = t < 0.5 ? S.Theme.base0A : S.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); + } + + function _fmt(gb) { + return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G"; + } + + // Header - vendor + usage% + Item { + width: parent.width + height: 28 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: S.SystemStats.gpuVendor.toUpperCase() + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 1 + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: S.SystemStats.gpuUsage + "%" + color: root._loadColor(S.SystemStats.gpuUsage) + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.fontFamily + font.bold: true + } + } + + // Usage bar + Item { + width: parent.width + height: 14 + + Item { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + height: 6 + + Rectangle { + anchors.fill: parent + color: S.Theme.base02 + radius: 3 + } + + Rectangle { + width: parent.width * Math.min(1, S.SystemStats.gpuUsage / 100) + height: parent.height + color: root._loadColor(S.SystemStats.gpuUsage) + radius: 3 + Behavior on width { + enabled: root.active + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } + } + + // Usage history sparkline + Canvas { + id: _sparkline + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + height: 36 + + property var _hist: S.SystemStats.gpuHistory + + on_HistChanged: if (root.active) + requestPaint() + + onVisibleChanged: if (visible) + requestPaint() + + onPaint: { + const ctx = getContext("2d"); + if (!ctx) + return; + ctx.clearRect(0, 0, width, height); + const d = _hist; + if (!d.length) + return; + const maxSamples = 60; + const bw = width / maxSamples; + const offset = maxSamples - d.length; + for (let i = 0; i < d.length; i++) { + const barH = Math.max(1, height * d[i] / 100); + const col = root._loadColor(d[i]); + ctx.fillStyle = col.toString(); + ctx.fillRect((offset + i) * bw, height - barH, Math.max(1, bw - 0.5), barH); + } + } + } + + // VRAM section + Rectangle { + width: parent.width - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: S.Theme.base03 + } + + Item { + width: parent.width + height: 22 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "VRAM" + color: S.Theme.base03 + font.pixelSize: S.Theme.fontSize - 3 + font.family: S.Theme.fontFamily + font.letterSpacing: 1 + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: root._fmt(S.SystemStats.gpuVramUsedGb) + " / " + root._fmt(S.SystemStats.gpuVramTotalGb) + color: root.accentColor + font.pixelSize: S.Theme.fontSize - 1 + font.family: S.Theme.fontFamily + font.bold: true + } + } + + Item { + width: parent.width + height: 12 + + Item { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + height: 5 + + Rectangle { + anchors.fill: parent + color: S.Theme.base02 + radius: 2 + } + + Rectangle { + width: S.SystemStats.gpuVramTotalGb > 0 ? parent.width * Math.min(1, S.SystemStats.gpuVramUsedGb / S.SystemStats.gpuVramTotalGb) : 0 + height: parent.height + color: root.accentColor + radius: 2 + Behavior on width { + enabled: root.active + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + } + } + } + + // Temperature row + Item { + width: parent.width + height: 22 + visible: S.SystemStats.gpuTempC > 0 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "Temp" + color: S.Theme.base04 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + + Text { + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: S.SystemStats.gpuTempC + "\u00B0C" + color: S.SystemStats.gpuTempC > 85 ? S.Theme.base08 : S.SystemStats.gpuTempC > 70 ? S.Theme.base0A : S.Theme.base05 + font.pixelSize: S.Theme.fontSize - 2 + font.family: S.Theme.fontFamily + } + } + + Item { + width: 1 + height: 4 + } +} diff --git a/shell/applets/qmldir b/shell/applets/qmldir index 9e72eb6..dc17e29 100644 --- a/shell/applets/qmldir +++ b/shell/applets/qmldir @@ -1,8 +1,10 @@ module applets # keep-sorted start BacklightApplet 1.0 BacklightApplet.qml +BatteryApplet 1.0 BatteryApplet.qml CpuApplet 1.0 CpuApplet.qml DiskApplet 1.0 DiskApplet.qml +GpuApplet 1.0 GpuApplet.qml HexWaveBackground 1.0 HexWaveBackground.qml MemoryApplet 1.0 MemoryApplet.qml MprisApplet 1.0 MprisApplet.qml diff --git a/shell/modules/BatteryModule.qml b/shell/modules/BatteryModule.qml index a1c703a..7368682 100644 --- a/shell/modules/BatteryModule.qml +++ b/shell/modules/BatteryModule.qml @@ -1,28 +1,20 @@ import QtQuick import Quickshell -import Quickshell.Io -import Quickshell.Services.UPower import "." as M import "../services" as S +import "../applets" as C M.BarSection { id: root spacing: S.Theme.moduleSpacing - opacity: S.Modules.battery.enable && (UPower.displayDevice?.isLaptopBattery ?? false) ? 1 : 0 + opacity: S.Modules.battery.enable && S.BatteryService.available ? 1 : 0 visible: opacity > 0 tooltip: "" - readonly property var dev: UPower.displayDevice - readonly property real pct: (dev?.percentage ?? 0) * 100 - readonly property bool charging: dev?.state === UPowerDeviceState.Charging - readonly property int _critThresh: S.Modules.battery.critical || 15 - readonly property int _warnThresh: S.Modules.battery.warning || 25 - readonly property bool _critical: pct < _critThresh && !charging - property color _stateColor: charging ? S.Theme.base0B : _critical ? S.Theme.base09 : pct < _warnThresh ? S.Theme.base0A : root.accentColor property real _blinkOpacity: 1 SequentialAnimation { - running: root._critical + running: S.BatteryService.critical loops: Animation.Infinite NumberAnimation { target: root @@ -42,48 +34,7 @@ M.BarSection { root._blinkOpacity = 1 } - // ── Notifications ──────────────────────────────────────────────────── - property bool _warnSent: false - property bool _critSent: false - - onChargingChanged: { - _warnSent = false; - _critSent = false; - } - onPctChanged: { - if (charging) - return; - if (pct < _critThresh && !_critSent) { - _critSent = true; - _warnSent = true; - _notif.command = ["notify-send", "--urgency=critical", "--icon=battery-low", "--category=device", "Very Low Battery", "Connect to power now!"]; - _notif.running = true; - } else if (pct < _warnThresh && !_warnSent) { - _warnSent = true; - _notif.command = ["notify-send", "--icon=battery-caution", "--category=device", "Low Battery"]; - _notif.running = true; - } - } - - Process { - id: _notif - } - - // ── History (always-running, 1440 samples @ 60s = 24h) ─────────────── - property var _history: [] - - Timer { - interval: 60000 - running: root.visible - repeat: true - triggeredOnStart: true - onTriggered: { - const h = root._history.concat([root.pct]); - root._history = h.length > 1440 ? h.slice(h.length - 1440) : h; - } - } - - // ── Panel state ────────────────────────────────────────────────────── + // Panel state property bool _pinned: false readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered readonly property bool _showPanel: _anyHover || _pinned @@ -101,23 +52,15 @@ M.BarSection { onTriggered: root._pinned = false } - function _fmtTime(secs) { - if (!secs || secs <= 0) - return ""; - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - return h > 0 ? h + "h " + m + "m" : m + "m"; - } - - // ── Bar widgets ────────────────────────────────────────────────────── + // Bar widgets M.BarIcon { icon: { - if (root.charging) + if (S.BatteryService.charging) return "\uDB80\uDC84"; const icons = ["\uDB80\uDC8E", "\uDB80\uDC7A", "\uDB80\uDC7B", "\uDB80\uDC7C", "\uDB80\uDC7D", "\uDB80\uDC7E", "\uDB80\uDC7F", "\uDB80\uDC80", "\uDB80\uDC81", "\uDB80\uDC82", "\uDB85\uDFE2"]; - return icons[Math.min(10, Math.floor(root.pct / 10))]; + return icons[Math.min(10, Math.floor(S.BatteryService.percent / 10))]; } - color: root._stateColor + color: S.BatteryService.stateColor opacity: root._blinkOpacity font.pixelSize: S.Theme.fontSize + 2 anchors.verticalCenter: parent.verticalCenter @@ -126,9 +69,9 @@ M.BarSection { } } M.BarLabel { - label: Math.round(root.pct) + "%" + label: Math.round(S.BatteryService.percent) + "%" minText: "100%" - color: root._stateColor + color: S.BatteryService.stateColor opacity: root._blinkOpacity anchors.verticalCenter: parent.verticalCenter TapHandler { @@ -136,7 +79,7 @@ M.BarSection { } } - // ── Hover panel ────────────────────────────────────────────────────── + // Hover panel M.HoverPanel { id: hoverPanel showPanel: root._showPanel @@ -147,272 +90,10 @@ M.BarSection { panelTitle: "Battery" contentWidth: 240 - // Header — pct + time - Item { - width: parent.width - height: 28 - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: { - const t = root.charging ? root.dev?.timeToFull : root.dev?.timeToEmpty; - const ts = root._fmtTime(t); - return Math.round(root.pct) + "%" + (ts ? " " + ts : ""); - } - color: root._stateColor - font.pixelSize: S.Theme.fontSize - font.family: S.Theme.fontFamily - font.bold: true - } - } - - // Progress bar - Item { - width: parent.width - height: 14 - - Item { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - height: 6 - - Rectangle { - anchors.fill: parent - color: S.Theme.base02 - radius: 3 - } - - Rectangle { - width: parent.width * Math.min(1, root.pct / 100) - height: parent.height - color: root._stateColor - radius: 3 - Behavior on width { - enabled: root._showPanel - NumberAnimation { - duration: 300 - easing.type: Easing.OutCubic - } - } - } - - // Warning threshold marker - Rectangle { - x: parent.width * (root._warnThresh / 100) - 1 - width: 1 - height: parent.height + 4 - anchors.verticalCenter: parent.verticalCenter - color: S.Theme.base0A - opacity: 0.6 - } - - // Critical threshold marker - Rectangle { - x: parent.width * (root._critThresh / 100) - 1 - width: 1 - height: parent.height + 4 - anchors.verticalCenter: parent.verticalCenter - color: S.Theme.base08 - opacity: 0.6 - } - } - } - - // 24h history sparkline (area chart) - Canvas { - id: _sparkline - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - height: 44 - - property var _hist: root._history - property color _col: root._stateColor - - on_HistChanged: if (root._showPanel) - requestPaint() - on_ColChanged: if (root._showPanel) - requestPaint() - - Connections { - target: root - function on_ShowPanelChanged() { - if (root._showPanel) - _sparkline.requestPaint(); - } - } - - onPaint: { - const ctx = getContext("2d"); - if (!ctx) - return; - ctx.clearRect(0, 0, width, height); - const d = _hist; - if (d.length < 2) - return; - - const maxSamples = 1440; - const xScale = width / maxSamples; - const xOffset = (maxSamples - d.length) * xScale; - - // Background tint - ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.07).toString(); - ctx.fillRect(0, 0, width, height); - - // Warning threshold line - const warnY = height - height * (root._warnThresh / 100); - ctx.strokeStyle = S.Theme.base0A.toString(); - ctx.globalAlpha = 0.3; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.beginPath(); - ctx.moveTo(0, warnY); - ctx.lineTo(width, warnY); - ctx.stroke(); - - // Critical threshold line - const critY = height - height * (root._critThresh / 100); - ctx.strokeStyle = S.Theme.base08.toString(); - ctx.beginPath(); - ctx.moveTo(0, critY); - ctx.lineTo(width, critY); - ctx.stroke(); - - ctx.setLineDash([]); - ctx.globalAlpha = 1.0; - - // Filled area under the curve - ctx.beginPath(); - ctx.moveTo(xOffset, height); - for (let i = 0; i < d.length; i++) { - const x = xOffset + i * xScale; - const y = height - height * (d[i] / 100); - ctx.lineTo(x, y); - } - ctx.lineTo(xOffset + (d.length - 1) * xScale, height); - ctx.closePath(); - ctx.fillStyle = Qt.rgba(_col.r, _col.g, _col.b, 0.18).toString(); - ctx.fill(); - - // Top line - ctx.beginPath(); - for (let i = 0; i < d.length; i++) { - const x = xOffset + i * xScale; - const y = height - height * (d[i] / 100); - if (i === 0) - ctx.moveTo(x, y); - else - ctx.lineTo(x, y); - } - ctx.strokeStyle = _col.toString(); - ctx.lineWidth = 1.5; - ctx.stroke(); - } - } - - // Footer: thresholds + time label - Item { - width: parent.width - height: 16 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "warn " + root._warnThresh + "% crit " + root._critThresh + "%" - color: S.Theme.base03 - font.pixelSize: S.Theme.fontSize - 3 - font.family: S.Theme.fontFamily - font.letterSpacing: 0.5 - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "24h" - color: S.Theme.base03 - font.pixelSize: S.Theme.fontSize - 3 - font.family: S.Theme.fontFamily - } - } - - // Separator - Rectangle { - width: parent.width - 16 - height: 1 - anchors.horizontalCenter: parent.horizontalCenter - color: S.Theme.base03 - } - - // Rate + health rows - Item { - width: parent.width - height: 20 - visible: (root.dev?.changeRate ?? 0) !== 0 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: root.charging ? "Charging" : "Discharging" - color: S.Theme.base04 - font.pixelSize: S.Theme.fontSize - 2 - font.family: S.Theme.fontFamily - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: { - const r = Math.abs(root.dev?.changeRate ?? 0); - return r > 0 ? r.toFixed(1) + " W" : ""; - } - color: root._stateColor - font.pixelSize: S.Theme.fontSize - 2 - font.family: S.Theme.fontFamily - } - } - - Item { - width: parent.width - height: 20 - visible: root.dev?.healthSupported ?? false - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "Health" - color: S.Theme.base04 - font.pixelSize: S.Theme.fontSize - 2 - font.family: S.Theme.fontFamily - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: Math.round((root.dev?.healthPercentage ?? 0) * 100) + "%" - color: { - const h = (root.dev?.healthPercentage ?? 1) * 100; - return h < 50 ? S.Theme.base08 : h < 75 ? S.Theme.base0A : S.Theme.base0B; - } - font.pixelSize: S.Theme.fontSize - 2 - font.family: S.Theme.fontFamily - } - } - - Item { - width: 1 - height: 4 + C.BatteryApplet { + width: hoverPanel.contentWidth + active: root._showPanel + accentColor: root.accentColor } } } diff --git a/shell/modules/GpuModule.qml b/shell/modules/GpuModule.qml index 50bba76..106463b 100644 --- a/shell/modules/GpuModule.qml +++ b/shell/modules/GpuModule.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import "." as M import "../services" as S +import "../applets" as C M.BarSection { id: root @@ -26,20 +27,8 @@ M.BarSection { onTriggered: root._pinned = false } - function _loadColor(pct) { - const t = Math.max(0, Math.min(100, pct)) / 100; - const a = t < 0.5 ? S.Theme.base0B : S.Theme.base0A; - const b = t < 0.5 ? S.Theme.base0A : S.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); - } - - function _fmt(gb) { - return gb >= 10 ? gb.toFixed(1) + "G" : gb.toFixed(2) + "G"; - } - M.BarIcon { - icon: "\uDB84\uDCB0" + icon: "\uEB4C" anchors.verticalCenter: parent.verticalCenter TapHandler { onTapped: root._pinned = !root._pinned @@ -64,210 +53,10 @@ M.BarSection { panelTitle: "GPU" contentWidth: 240 - // Header — vendor + usage% - Item { - width: parent.width - height: 28 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: S.SystemStats.gpuVendor.toUpperCase() - color: S.Theme.base03 - font.pixelSize: S.Theme.fontSize - 3 - font.family: S.Theme.fontFamily - font.letterSpacing: 1 - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: S.SystemStats.gpuUsage + "%" - color: root._loadColor(S.SystemStats.gpuUsage) - font.pixelSize: S.Theme.fontSize - font.family: S.Theme.fontFamily - font.bold: true - } - } - - // Usage bar - Item { - width: parent.width - height: 14 - - Item { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - height: 6 - - Rectangle { - anchors.fill: parent - color: S.Theme.base02 - radius: 3 - } - - Rectangle { - width: parent.width * Math.min(1, S.SystemStats.gpuUsage / 100) - height: parent.height - color: root._loadColor(S.SystemStats.gpuUsage) - radius: 3 - Behavior on width { - enabled: root._showPanel - NumberAnimation { - duration: 200 - easing.type: Easing.OutCubic - } - } - } - } - } - - // Usage history sparkline - Canvas { - id: _sparkline - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - height: 36 - - property var _hist: S.SystemStats.gpuHistory - - on_HistChanged: if (root._showPanel) - requestPaint() - - Connections { - target: root - function on_ShowPanelChanged() { - if (root._showPanel) - _sparkline.requestPaint(); - } - } - - onPaint: { - const ctx = getContext("2d"); - if (!ctx) - return; - ctx.clearRect(0, 0, width, height); - const d = _hist; - if (!d.length) - return; - const maxSamples = 60; - const bw = width / maxSamples; - const offset = maxSamples - d.length; - for (let i = 0; i < d.length; i++) { - const barH = Math.max(1, height * d[i] / 100); - const col = root._loadColor(d[i]); - ctx.fillStyle = col.toString(); - ctx.fillRect((offset + i) * bw, height - barH, Math.max(1, bw - 0.5), barH); - } - } - } - - // VRAM section - Rectangle { - width: parent.width - 16 - height: 1 - anchors.horizontalCenter: parent.horizontalCenter - color: S.Theme.base03 - } - - Item { - width: parent.width - height: 22 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "VRAM" - color: S.Theme.base03 - font.pixelSize: S.Theme.fontSize - 3 - font.family: S.Theme.fontFamily - font.letterSpacing: 1 - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: root._fmt(S.SystemStats.gpuVramUsedGb) + " / " + root._fmt(S.SystemStats.gpuVramTotalGb) - color: root.accentColor - font.pixelSize: S.Theme.fontSize - 1 - font.family: S.Theme.fontFamily - font.bold: true - } - } - - Item { - width: parent.width - height: 12 - - Item { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - height: 5 - - Rectangle { - anchors.fill: parent - color: S.Theme.base02 - radius: 2 - } - - Rectangle { - width: S.SystemStats.gpuVramTotalGb > 0 ? parent.width * Math.min(1, S.SystemStats.gpuVramUsedGb / S.SystemStats.gpuVramTotalGb) : 0 - height: parent.height - color: root.accentColor - radius: 2 - Behavior on width { - enabled: root._showPanel - NumberAnimation { - duration: 200 - easing.type: Easing.OutCubic - } - } - } - } - } - - // Temperature row - Item { - width: parent.width - height: 22 - visible: S.SystemStats.gpuTempC > 0 - - Text { - anchors.left: parent.left - anchors.leftMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: "Temp" - color: S.Theme.base04 - font.pixelSize: S.Theme.fontSize - 2 - font.family: S.Theme.fontFamily - } - - Text { - anchors.right: parent.right - anchors.rightMargin: 12 - anchors.verticalCenter: parent.verticalCenter - text: S.SystemStats.gpuTempC + "\u00B0C" - color: S.SystemStats.gpuTempC > 85 ? S.Theme.base08 : S.SystemStats.gpuTempC > 70 ? S.Theme.base0A : S.Theme.base05 - font.pixelSize: S.Theme.fontSize - 2 - font.family: S.Theme.fontFamily - } - } - - Item { - width: 1 - height: 4 + C.GpuApplet { + width: hoverPanel.contentWidth + active: root._showPanel + accentColor: root.accentColor } } } diff --git a/shell/services/BatteryService.qml b/shell/services/BatteryService.qml new file mode 100644 index 0000000..dc64642 --- /dev/null +++ b/shell/services/BatteryService.qml @@ -0,0 +1,77 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.UPower +import "." as S + +QtObject { + id: root + + readonly property var dev: UPower.displayDevice + readonly property bool available: dev?.isLaptopBattery ?? false + readonly property real pct: (dev?.percentage ?? 0) * 100 + readonly property bool charging: dev?.state === UPowerDeviceState.Charging + readonly property real changeRate: dev?.changeRate ?? 0 + readonly property int timeToFull: dev?.timeToFull ?? 0 + readonly property int timeToEmpty: dev?.timeToEmpty ?? 0 + readonly property bool healthSupported: dev?.healthSupported ?? false + readonly property real healthPct: (dev?.healthPercentage ?? 1) * 100 + + readonly property int critThresh: S.Modules.battery.critical || 15 + readonly property int warnThresh: S.Modules.battery.warning || 25 + readonly property bool critical: pct < critThresh && !charging + + readonly property color stateColor: charging ? S.Theme.base0B : critical ? S.Theme.base09 : pct < warnThresh ? S.Theme.base0A : S.Theme.base05 + + // 24h history (1440 samples @ 60s) + property var history: [] + + property var _histTimer: Timer { + interval: 60000 + running: root.available + repeat: true + triggeredOnStart: true + onTriggered: { + const h = root.history.concat([root.pct]); + root.history = h.length > 1440 ? h.slice(h.length - 1440) : h; + } + } + + // Low battery notifications + property bool _warnSent: false + property bool _critSent: false + + property var _chargingWatcher: Connections { + target: root + function onChargingChanged() { + root._warnSent = false; + root._critSent = false; + } + function onPctChanged() { + if (root.charging) + return; + if (root.pct < root.critThresh && !root._critSent) { + root._critSent = true; + root._warnSent = true; + _notifProc.command = ["notify-send", "--urgency=critical", "--icon=battery-low", "--category=device", "Very Low Battery", "Connect to power now!"]; + _notifProc.running = true; + } else if (root.pct < root.warnThresh && !root._warnSent) { + root._warnSent = true; + _notifProc.command = ["notify-send", "--icon=battery-caution", "--category=device", "Low Battery"]; + _notifProc.running = true; + } + } + } + + property var _notifProc: Process {} + + function fmtTime(secs) { + if (!secs || secs <= 0) + return ""; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return h > 0 ? h + "h " + m + "m" : m + "m"; + } +} diff --git a/shell/services/qmldir b/shell/services/qmldir index 1167a3c..339d03e 100644 --- a/shell/services/qmldir +++ b/shell/services/qmldir @@ -2,6 +2,7 @@ module services # keep-sorted start NotifItem 1.0 NotifItem.qml singleton BacklightService 1.0 BacklightService.qml +singleton BatteryService 1.0 BatteryService.qml singleton BluetoothService 1.0 BluetoothService.qml singleton IdleInhibitService 1.0 IdleInhibitService.qml singleton LockService 1.0 LockService.qml