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/toasts')
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/toasts')
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