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/qmldir b/shell/applets/qmldir index 89c7fa7..dc17e29 100644 --- a/shell/applets/qmldir +++ b/shell/applets/qmldir @@ -1,6 +1,7 @@ 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 diff --git a/shell/modules/BatteryModule.qml b/shell/modules/BatteryModule.qml index f5b18b0..7368682 100644 --- a/shell/modules/BatteryModule.qml +++ b/shell/modules/BatteryModule.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import "." as M import "../services" as S +import "../applets" as C M.BarSection { id: root @@ -57,7 +58,7 @@ M.BarSection { 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(S.BatteryService.pct / 10))]; + return icons[Math.min(10, Math.floor(S.BatteryService.percent / 10))]; } color: S.BatteryService.stateColor opacity: root._blinkOpacity @@ -68,7 +69,7 @@ M.BarSection { } } M.BarLabel { - label: Math.round(S.BatteryService.pct) + "%" + label: Math.round(S.BatteryService.percent) + "%" minText: "100%" color: S.BatteryService.stateColor opacity: root._blinkOpacity @@ -89,273 +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 = S.BatteryService.charging ? S.BatteryService.timeToFull : S.BatteryService.timeToEmpty; - const ts = S.BatteryService.fmtTime(t); - return Math.round(S.BatteryService.pct) + "%" + (ts ? " " + ts : ""); - } - color: S.BatteryService.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.pct / 100) - height: parent.height - color: S.BatteryService.stateColor - radius: 3 - Behavior on width { - enabled: root._showPanel - 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: S.BatteryService.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 * (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: S.BatteryService.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.healthPct) + "%" - color: { - const h = S.BatteryService.healthPct; - 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 } } }