From 0ca5d505468d8d9dfdf01531f869ff5904f25ccc Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:43:45 +1000 Subject: utilities: add toasts --- modules/utilities/toasts/ToastItem.qml | 132 ++++++++++++++++++++++++++++++++ modules/utilities/toasts/Toasts.qml | 135 +++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 modules/utilities/toasts/ToastItem.qml create mode 100644 modules/utilities/toasts/Toasts.qml (limited to 'modules/utilities') diff --git a/modules/utilities/toasts/ToastItem.qml b/modules/utilities/toasts/ToastItem.qml new file mode 100644 index 0000000..fa3aa18 --- /dev/null +++ b/modules/utilities/toasts/ToastItem.qml @@ -0,0 +1,132 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import Caelestia +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property Toast modelData + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: layout.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.normal + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3successContainer; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3secondary; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3errorContainer; + return Colours.palette.m3surface; + } + + border.width: 1 + border.color: { + let colour = Colours.palette.m3outlineVariant; + if (root.modelData.type === Toast.Success) + colour = Colours.palette.m3success; + if (root.modelData.type === Toast.Warning) + colour = Colours.palette.m3secondaryContainer; + if (root.modelData.type === Toast.Error) + colour = Colours.palette.m3error; + return Qt.alpha(colour, 0.3); + } + + Elevation { + anchors.fill: parent + radius: parent.radius + opacity: parent.opacity + z: -1 + level: 3 + } + + RowLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.smaller + anchors.leftMargin: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.normal + spacing: Appearance.spacing.normal + + StyledRect { + radius: Appearance.rounding.normal + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3success; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3secondaryContainer; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3error; + return Colours.palette.m3surfaceContainerHigh; + } + + implicitWidth: implicitHeight + implicitHeight: icon.implicitHeight + Appearance.padding.normal * 2 + + MaterialIcon { + id: icon + + anchors.centerIn: parent + text: root.modelData.icon + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3onSuccess; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3onSecondaryContainer; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3onError; + return Colours.palette.m3onSurfaceVariant; + } + font.pointSize: Appearance.font.size.large + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + id: title + + Layout.fillWidth: true + text: root.modelData.title + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3onSuccessContainer; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3onSecondary; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3onErrorContainer; + return Colours.palette.m3onSurface; + } + font.pointSize: Appearance.font.size.normal + } + + StyledText { + Layout.fillWidth: true + text: root.modelData.message + color: { + if (root.modelData.type === Toast.Success) + return Colours.palette.m3onSuccessContainer; + if (root.modelData.type === Toast.Warning) + return Colours.palette.m3onSecondary; + if (root.modelData.type === Toast.Error) + return Colours.palette.m3onErrorContainer; + return Colours.palette.m3onSurface; + } + opacity: 0.8 + } + } + } + + Behavior on border.color { + CAnim {} + } +} diff --git a/modules/utilities/toasts/Toasts.qml b/modules/utilities/toasts/Toasts.qml new file mode 100644 index 0000000..c23790d --- /dev/null +++ b/modules/utilities/toasts/Toasts.qml @@ -0,0 +1,135 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import Caelestia +import Quickshell +import QtQuick +import QtQuick.Layouts + +Item { + id: root + + readonly property int spacing: Appearance.spacing.small + property bool flag + + implicitWidth: Config.utilities.sizes.width - Appearance.padding.normal * 2 + implicitHeight: { + let h = -spacing; + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i); + if (!item.modelData.closed && !item.previewHidden) + h += item.implicitHeight + spacing; + } + return h; + } + + Repeater { + id: repeater + + model: ScriptModel { + values: { + const toasts = []; + let count = 0; + for (const toast of Toaster.toasts) { + toasts.push(toast); + if (!toast.closed) { + count++; + if (count > Config.utilities.maxToasts) + break; + } + } + return toasts; + } + onValuesChanged: root.flagChanged() + } + + MouseArea { + id: toast + + required property int index + required property Toast modelData + + readonly property bool previewHidden: { + let extraHidden = 0; + for (let i = 0; i < index; i++) + if (Toaster.toasts[i].closed) + extraHidden++; + return index >= Config.utilities.maxToasts + extraHidden; + } + + opacity: modelData.closed || previewHidden ? 0 : 1 + scale: modelData.closed || previewHidden ? 0.7 : 1 + + anchors.bottomMargin: { + root.flag; // Force update + let y = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i); + if (item && !item.modelData.closed && !item.previewHidden) + y += item.implicitHeight + root.spacing; + } + return y; + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + implicitHeight: toastInner.implicitHeight + + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + onClicked: modelData.close() + + Component.onCompleted: modelData.lock(this) + + Anim { + Component.onCompleted: running = !toast.previewHidden + + target: toast + properties: "opacity,scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + + ParallelAnimation { + running: toast.modelData.closed + onStarted: toast.anchors.bottomMargin = toast.anchors.bottomMargin + onFinished: toast.modelData.unlock(toast) + + Anim { + target: toast + property: "opacity" + to: 0 + } + Anim { + target: toast + property: "scale" + to: 0.7 + } + } + + ToastItem { + id: toastInner + + modelData: toast.modelData + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on anchors.bottomMargin { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } + } +} -- cgit v1.2.3-freya From 61b21f389f90c2d6c7ed9cd1b206b6a31ae93d86 Mon Sep 17 00:00:00 2001 From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com> Date: Sun, 21 Sep 2025 17:33:28 +1000 Subject: feat: add battery warnings Closes #73 Closes #117 --- README.md | 27 ++++++++++++++++- config/GeneralConfig.qml | 26 ++++++++++++++++ modules/BatteryMonitor.qml | 54 ++++++++++++++++++++++++++++++++++ modules/utilities/toasts/ToastItem.qml | 3 ++ modules/utilities/toasts/Toasts.qml | 7 +++++ shell.qml | 1 + 6 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 modules/BatteryMonitor.qml (limited to 'modules/utilities') diff --git a/README.md b/README.md index c63769d..421b012 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,30 @@ default, you must create it manually. "terminal": ["foot"], "audio": ["pavucontrol"] }, + "battery": { + "warnLevels": [ + { + "level": 20, + "title": "Low battery", + "message": "You might want to plug in a charger", + "icon": "battery_android_frame_2" + }, + { + "level": 10, + "title": "Did you see the previous message?", + "message": "You should probably plug in a charger now", + "icon": "battery_android_frame_1" + }, + { + "level": 5, + "title": "Critical battery level", + "message": "PLUG THE CHARGER RIGHT NOW!!", + "icon": "battery_android_alert", + "critical": true + } + ], + "criticalLevel": 3 + }, "idle": { "inhibitWhenAudio": true, "lockTimeout": 180, @@ -530,7 +554,8 @@ default, you must create it manually. "enabled": true }, "utilities": { - "enabled": true + "enabled": true, + "maxToasts": 4 } } ``` diff --git a/config/GeneralConfig.qml b/config/GeneralConfig.qml index dc6222e..4d7a79c 100644 --- a/config/GeneralConfig.qml +++ b/config/GeneralConfig.qml @@ -3,6 +3,7 @@ import Quickshell.Io JsonObject { property Apps apps: Apps {} property Idle idle: Idle {} + property Battery battery: Battery {} component Apps: JsonObject { property list terminal: ["foot"] @@ -17,4 +18,29 @@ JsonObject { property real dpmsTimeout: 300 // 5 mins property real sleepTimeout: 600 // 10 mins } + + component Battery: JsonObject { + property list warnLevels: [ + { + level: 20, + title: qsTr("Low battery"), + message: qsTr("You might want to plug in a charger"), + icon: "battery_android_frame_2" + }, + { + level: 10, + title: qsTr("Did you see the previous message?"), + message: qsTr("You should probably plug in a charger now"), + icon: "battery_android_frame_1" + }, + { + level: 5, + title: qsTr("Critical battery level"), + message: qsTr("PLUG THE CHARGER RIGHT NOW!!"), + icon: "battery_android_alert", + critical: true + }, + ] + property int criticalLevel: 3 + } } diff --git a/modules/BatteryMonitor.qml b/modules/BatteryMonitor.qml new file mode 100644 index 0000000..f407edf --- /dev/null +++ b/modules/BatteryMonitor.qml @@ -0,0 +1,54 @@ +import qs.config +import Caelestia +import Quickshell +import Quickshell.Services.UPower +import QtQuick + +Scope { + id: root + + readonly property list warnLevels: [...Config.general.battery.warnLevels].sort((a, b) => b.level - a.level) + + Connections { + target: UPower + + function onOnBatteryChanged(): void { + if (UPower.onBattery) { + Toaster.toast(qsTr("Charger unplugged"), qsTr("Battery is now on AC"), "power_off"); + } else { + Toaster.toast(qsTr("Charger plugged in"), qsTr("Battery is charging"), "battery_android_frame_bolt"); + for (const level of root.warnLevels) + level.warned = false; + } + } + } + + Connections { + target: UPower.displayDevice + + function onPercentageChanged(): void { + if (!UPower.onBattery) + return; + + const p = UPower.displayDevice.percentage; + for (const level of root.warnLevels) { + if (p <= level.level && !level.warned) { + level.warned = true; + Toaster.toast(level.title ?? qsTr("Battery warning"), level.message ?? qsTr("Battery level is low"), level.icon ?? "battery_android_alert", level.critical ? Toast.Error : Toast.Warning); + } + } + + if (!hibernateTimer.running && p <= Config.general.battery.criticalLevel) { + Toaster.toast(qsTr("Hibernating in 5 seconds"), qsTr("Hibernating to prevent data loss"), "battery_android_alert", Toast.Error); + hibernateTimer.start(); + } + } + } + + Timer { + id: hibernateTimer + + interval: 5000 + onTriggered: Quickshell.execDetached(["systemctl", "hibernate"]) + } +} diff --git a/modules/utilities/toasts/ToastItem.qml b/modules/utilities/toasts/ToastItem.qml index fa3aa18..481b831 100644 --- a/modules/utilities/toasts/ToastItem.qml +++ b/modules/utilities/toasts/ToastItem.qml @@ -107,10 +107,12 @@ StyledRect { return Colours.palette.m3onSurface; } font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight } StyledText { Layout.fillWidth: true + textFormat: Text.StyledText text: root.modelData.message color: { if (root.modelData.type === Toast.Success) @@ -122,6 +124,7 @@ StyledRect { return Colours.palette.m3onSurface; } opacity: 0.8 + elide: Text.ElideRight } } } diff --git a/modules/utilities/toasts/Toasts.qml b/modules/utilities/toasts/Toasts.qml index c23790d..c9a8d4d 100644 --- a/modules/utilities/toasts/Toasts.qml +++ b/modules/utilities/toasts/Toasts.qml @@ -58,6 +58,11 @@ Item { return index >= Config.utilities.maxToasts + extraHidden; } + onPreviewHiddenChanged: { + if (initAnim.running && previewHidden) + initAnim.stop(); + } + opacity: modelData.closed || previewHidden ? 0 : 1 scale: modelData.closed || previewHidden ? 0.7 : 1 @@ -83,6 +88,8 @@ Item { Component.onCompleted: modelData.lock(this) Anim { + id: initAnim + Component.onCompleted: running = !toast.previewHidden target: toast diff --git a/shell.qml b/shell.qml index a56caae..3ce7776 100644 --- a/shell.qml +++ b/shell.qml @@ -18,6 +18,7 @@ ShellRoot { } Shortcuts {} + BatteryMonitor {} IdleMonitors { lock: lock } -- cgit v1.2.3-freya