diff --git a/Modules/Bar/Bar.qml b/Modules/Bar/Bar.qml index 9f3669d..9492961 100644 --- a/Modules/Bar/Bar.qml +++ b/Modules/Bar/Bar.qml @@ -76,6 +76,10 @@ Variants { anchors.verticalCenter: bar.verticalCenter spacing: Style.marginSmall * scaling + Battery { + anchors.verticalCenter: parent.verticalCenter + } + Clock { anchors.verticalCenter: parent.verticalCenter } diff --git a/Modules/Bar/Battery.qml b/Modules/Bar/Battery.qml new file mode 100644 index 0000000..7b1f33a --- /dev/null +++ b/Modules/Bar/Battery.qml @@ -0,0 +1,119 @@ +import QtQuick +import Quickshell +import Quickshell.Services.UPower +import QtQuick.Layouts +import qs.Services +import qs.Widgets +import "../../Helpers/Duration.js" as Duration + +Item { + id: root + + // Test mode + property bool testMode: true + property int testPercent: 49 + property bool testCharging: false + + property var battery: UPower.displayDevice + property bool isReady: testMode ? true : (battery && battery.ready && battery.isLaptopBattery && battery.isPresent) + property real percent: testMode ? testPercent : (isReady ? (battery.percentage * 100) : 0) + property bool charging: testMode ? testCharging : (isReady ? battery.state === UPowerDeviceState.Charging : false) + property bool show: isReady && percent > 0 + + // Choose icon based on charge and charging state + function batteryIcon() { + if (!show) + return ""; + + if (charging) + return "battery_android_bolt"; + + if (percent >= 95) + return "battery_android_full"; + + // Hardcoded battery symbols + if (percent >= 85) + return "battery_android_6"; + if (percent >= 70) + return "battery_android_5"; + if (percent >= 55) + return "battery_android_4"; + if (percent >= 40) + return "battery_android_3"; + if (percent >= 25) + return "battery_android_2"; + if (percent >= 10) + return "battery_android_1"; + if (percent >= 0) + return "battery_android_0"; + } + + visible: testMode || (isReady && battery.isLaptopBattery) + width: pill.width + height: pill.height + + NPill { + id: pill + icon: root.batteryIcon() + text: Math.round(root.percent) + "%" + pillColor: Colors.surfaceVariant + iconCircleColor: Colors.accentPrimary + iconTextColor: Colors.backgroundPrimary + textColor: charging ? Colors.accentPrimary : Colors.textPrimary + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: { + pill.showDelayed(); + batteryTooltip.show(); + } + onExited: { + pill.hide(); + batteryTooltip.show(); + } + } + NTooltip { + id: batteryTooltip + positionAbove: false + target: pill + delay: Style.tooltipDelayLong + text: { + let lines = []; + if (!root.isReady) { + return ""; + } + + if (root.battery.timeToEmpty > 0) { + lines.push("Time left: " + Time.formatVagueHumanReadableTime(root.battery.timeToEmpty)); + } + + if (root.battery.timeToFull > 0) { + lines.push("Time until full: " + Time.formatVagueHumanReadableTime(root.battery.timeToFull)); + } + + if (root.battery.changeRate !== undefined) { + const rate = root.battery.changeRate; + if (rate > 0) { + lines.push(root.charging ? "Charging rate: " + rate.toFixed(2) + " W" : "Discharging rate: " + rate.toFixed(2) + " W"); + } + else if (rate < 0) { + lines.push("Discharging rate: " + Math.abs(rate).toFixed(2) + " W"); + } + else { + lines.push("Estimating..."); + } + } + else { + lines.push(root.charging ? "Charging" : "Discharging"); + } + + + if (root.battery.healthPercentage !== undefined && root.battery.healthPercentage > 0) { + lines.push("Health: " + Math.round(root.battery.healthPercentage) + "%"); + } + return lines.join("\n"); + } + + } + } +} \ No newline at end of file diff --git a/Services/Style.qml b/Services/Style.qml index 12b0b6e..44c2031 100644 --- a/Services/Style.qml +++ b/Services/Style.qml @@ -44,12 +44,15 @@ Singleton { property int animationNormal: 300 property int animationSlow: 500 + // Dimensions property int barHeight: 36 property int baseWidgetSize: 32 property int sliderWidth: 200 - // Delay + // Delays property int tooltipDelay: 300 + property int tooltipDelayLong: 1500 + property int pillDelay: 500 // Margins and spacing property int marginTiny: 4 diff --git a/Widgets/NPill.qml b/Widgets/NPill.qml new file mode 100644 index 0000000..71aaa06 --- /dev/null +++ b/Widgets/NPill.qml @@ -0,0 +1,202 @@ +import QtQuick +import QtQuick.Controls +import qs.Services + +Item { + id: revealPill + + readonly property real scaling: Scaling.scale(screen) + + property string icon: "" + property string text: "" + property color pillColor: Colors.surfaceVariant + property color textColor: Colors.textPrimary + property color iconCircleColor: Colors.accentPrimary + property color iconTextColor: Colors.backgroundPrimary + property color collapsedIconColor: Colors.textPrimary + property real sizeMultiplier: 0.8 + property int pillHeight: Style.baseWidgetSize * sizeMultiplier * scaling + property int iconSize: Style.baseWidgetSize * sizeMultiplier * scaling + property int pillPaddingHorizontal: 14 * scaling + property bool autoHide: false + + // Internal state + property bool showPill: false + property bool shouldAnimateHide: false + + // Exposed width logic + readonly property int pillOverlap: iconSize / 2 + readonly property int maxPillWidth: Math.max(1, textItem.implicitWidth + pillPaddingHorizontal * 2 + pillOverlap) + + signal shown + signal hidden + + width: iconSize + (showPill ? maxPillWidth - pillOverlap : 0) + height: pillHeight + + Rectangle { + id: pill + width: showPill ? maxPillWidth : 1 + height: pillHeight + x: (iconCircle.x + iconCircle.width / 2) - width + opacity: showPill ? 1 : 0 + color: pillColor + topLeftRadius: pillHeight / 2 + bottomLeftRadius: pillHeight / 2 + anchors.verticalCenter: parent.verticalCenter + + Text { + id: textItem + anchors.centerIn: parent + text: revealPill.text + font.pointSize: Colors.fontSizeSmall * scaling + font.family: Settings.data.ui.fontFamily + font.weight: Font.Bold + color: textColor + visible: showPill + } + + Behavior on width { + enabled: showAnim.running || hideAnim.running + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + } + Behavior on opacity { + enabled: showAnim.running || hideAnim.running + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + } + + Rectangle { + id: iconCircle + width: iconSize + height: iconSize + radius: width / 2 + color: showPill ? iconCircleColor : "transparent" + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + + Behavior on color { + ColorAnimation { + duration: 200 + easing.type: Easing.InOutQuad + } + } + + Text { + anchors.centerIn: parent + font.family: showPill ? "Material Symbols Rounded" : "Material Symbols Outlined" + font.pointSize: Colors.fontSizeSmall * scaling + text: revealPill.icon + color: showPill ? iconTextColor : collapsedIconColor + } + } + + ParallelAnimation { + id: showAnim + running: false + NumberAnimation { + target: pill + property: "width" + from: 1 + to: maxPillWidth + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + NumberAnimation { + target: pill + property: "opacity" + from: 0 + to: 1 + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + onStarted: { + showPill = true; + } + onStopped: { + delayedHideAnim.start(); + shown(); + } + } + + SequentialAnimation { + id: delayedHideAnim + running: false + PauseAnimation { + duration: 2500 + } + ScriptAction { + script: if (shouldAnimateHide) + hideAnim.start() + } + } + + ParallelAnimation { + id: hideAnim + running: false + NumberAnimation { + target: pill + property: "width" + from: maxPillWidth + to: 1 + duration: Style.animationNormal + easing.type: Easing.InCubic + } + NumberAnimation { + target: pill + property: "opacity" + from: 1 + to: 0 + duration: Style.animationNormal + easing.type: Easing.InCubic + } + onStopped: { + showPill = false; + shouldAnimateHide = false; + hidden(); + } + } + + function show() { + if (!showPill) { + shouldAnimateHide = autoHide; + showAnim.start(); + } else { + hideAnim.stop(); + delayedHideAnim.restart(); + } + } + + function hide() { + if (showPill) { + hideAnim.start(); + } + showTimer.stop(); + } + + function showDelayed() { + if (!showPill) { + shouldAnimateHide = autoHide; + showTimer.start(); + } else { + hideAnim.stop(); + delayedHideAnim.restart(); + } + } + + Timer { + id: showTimer + interval: Style.pillDelay + onTriggered: { + if (!showPill) { + showAnim.start(); + } + } + } +} \ No newline at end of file