From a12713d06b85f4ba07e205c48836b657df3fab1f Mon Sep 17 00:00:00 2001 From: rayyan Date: Fri, 9 Oct 2026 21:37:46 -0300 Subject: [PATCH 1/3] [global] power: add native battery alerts and Hub settings Note: New: Configure low-battery warning and critical alerts in Graphics & Power settings --- docs/power.md | 20 +++ ryoku/hub/CHANGELOG.md | 5 + .../pages/BatteryAlertsSettings.qml | 127 ++++++++++++++++++ ryoku/hub/quickshell/pages/GpuPage.qml | 5 +- ryoku/shell/CHANGELOG.md | 4 + ryoku/shell/ipc/batteryalerts.go | 39 ++++++ ryoku/shell/ipc/batteryalerts_test.go | 111 +++++++++++++++ ryoku/shell/ipc/settings.go | 6 + ryoku/shell/ipc/settings_test.go | 8 +- .../python/singletons/system/SysNotif.qml | 36 +---- .../shell/services/BatteryAlertPolicy.js | 18 +++ .../shell/services/BatteryAlerts.qml | 101 ++++++++++++++ .../quickshell/shell/services/Config.qml | 2 + ryoku/shell/quickshell/shell/services/qmldir | 1 + ryoku/shell/quickshell/shell/shell.qml | 2 +- tests/ui/battery-alerts-probe.qml | 76 +++++++++++ tests/ui/battery-alerts-probe.sh | 57 ++++++++ 17 files changed, 577 insertions(+), 41 deletions(-) create mode 100644 ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml create mode 100644 ryoku/shell/ipc/batteryalerts.go create mode 100644 ryoku/shell/ipc/batteryalerts_test.go create mode 100644 ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js create mode 100644 ryoku/shell/quickshell/shell/services/BatteryAlerts.qml create mode 100644 tests/ui/battery-alerts-probe.qml create mode 100755 tests/ui/battery-alerts-probe.sh diff --git a/docs/power.md b/docs/power.md index ec670a2ce8..86a509c268 100644 --- a/docs/power.md +++ b/docs/power.md @@ -9,6 +9,26 @@ Reference machine for every number below: ASUS ROG Zephyrus G14 GA402XV, Ryzen 9 `power-profiles-daemon` running, kernel 7.1. Numbers are specific to that chassis; the *shape* of the conclusions holds for AMD laptops generally. +## Low-battery alerts + +Open **Win+, → Graphics & Power → Battery** to enable alerts, set the warning +and critical percentages, or send a test notification. Defaults are 25% and +10%. Warning accepts 5–50%; critical accepts 1–49% and must be below warning. +These settings are independent of hardware support for a charge ceiling. + +While discharging, Ryoku sends “Battery low” at the warning level and “Battery +critical” at the critical level, with “Battery at N%. Connect your charger.” +Each level alerts once until AC power reconnects. Starting below the critical +level sends only the critical alert. Delivery failures can retry without +consuming that alert. The service runs regardless of the selected bar style +or whether its battery indicator is visible. + +Alerts use the normal notification system and respect Do Not Disturb. The +native service replaces the Python bar's separate low-battery reminders while +preserving its full-charge notification. If you installed the Battery Alerts +community add-on, disable its alerts when enabling the native service to avoid +duplicate reminders. + ## The layers, from the one that matters most 1. **Which GPU renders your desktop.** On a hybrid laptop this dominates diff --git a/ryoku/hub/CHANGELOG.md b/ryoku/hub/CHANGELOG.md index d657a7a9fd..260685ea18 100644 --- a/ryoku/hub/CHANGELOG.md +++ b/ryoku/hub/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added +- Battery settings include low-battery alerts, warning and critical percentages, + and a test notification. Changes apply immediately and critical always stays + below warning (`quickshell/pages/BatteryAlertsSettings.qml`). + ### Changed - **Add-ons is a library of what you installed, not a wall of toggles.** Desktop widgets no longer appear there; they are managed in Desktop. Shell diff --git a/ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml b/ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml new file mode 100644 index 0000000000..f087ba77cb --- /dev/null +++ b/ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml @@ -0,0 +1,127 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Ryoku.Ui +import Ryoku.Ui.Singletons +import "../Singletons" + +Column { + id: root + + property bool busy: false + property string feedback: "" + readonly property bool alertsEnabled: { + Settings.revision; + return Settings.get("batteryAlerts.enabled") !== false; + } + readonly property int warningPercent: { + Settings.revision; + var value = Settings.get("batteryAlerts.warningPercent"); + return typeof value === "number" ? Math.max(5, Math.min(50, Math.round(value))) : 25; + } + readonly property int criticalPercent: { + Settings.revision; + var value = Settings.get("batteryAlerts.criticalPercent"); + return Math.max(1, Math.min(root.warningPercent - 1, typeof value === "number" ? Math.round(value) : 10)); + } + + function save(key, value) { + root.busy = true; + root.feedback = ""; + Settings.patch("batteryAlerts." + key, value, function(ok, error) { + root.busy = false; + root.feedback = ok ? "" : I18n.tr(error); + }); + } + + function testNotification() { + root.busy = true; + root.feedback = ""; + Settings.send("battery-alerts.test", {}, function(ok, error) { + root.busy = false; + root.feedback = ok ? I18n.tr("Test notification requested.") : I18n.tr(error); + }); + } + + SettingRow { + width: parent.width + label: I18n.tr("Low-battery alerts") + desc: I18n.tr("Warn while the battery is discharging. Alerts respect Do Not Disturb.") + controlWidth: 54 + enabled: Settings.ready && !root.busy + Sw { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + on: root.alertsEnabled + onToggled: value => root.save("enabled", value) + } + } + + SettingRow { + width: parent.width + divider: true + label: I18n.tr("Warning percentage") + desc: I18n.tr("First reminder to plug in. Must be above the critical percentage.") + controlWidth: 58 + unit: "%" + value: String(root.warningPercent) + editableValue: true + enabled: Settings.ready && root.alertsEnabled && !root.busy + onValueCommitted: text => { + var value = Number(text); + if (text.trim() !== "" && isFinite(value)) + root.save("warningPercent", Math.max(Math.max(5, root.criticalPercent + 1), Math.min(50, Math.round(value)))); + } + Step { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + from: Math.max(5, root.criticalPercent + 1) + to: 50 + stepBy: 1 + value: root.warningPercent + onModified: value => root.save("warningPercent", value) + } + } + + SettingRow { + width: parent.width + divider: true + label: I18n.tr("Critical percentage") + desc: I18n.tr("Urgent reminder to plug in. Must be below the warning percentage.") + controlWidth: 58 + unit: "%" + value: String(root.criticalPercent) + editableValue: true + enabled: Settings.ready && root.alertsEnabled && !root.busy + onValueCommitted: text => { + var value = Number(text); + if (text.trim() !== "" && isFinite(value)) + root.save("criticalPercent", Math.max(1, Math.min(root.warningPercent - 1, Math.round(value)))); + } + Step { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + from: 1 + to: root.warningPercent - 1 + stepBy: 1 + value: root.criticalPercent + onModified: value => root.save("criticalPercent", value) + } + } + + SettingRow { + width: parent.width + divider: true + label: I18n.tr("Test notification") + desc: root.feedback !== "" ? root.feedback : I18n.tr("Preview a battery alert without changing your battery level.") + controlWidth: testButton.implicitWidth + Btn { + id: testButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: I18n.tr("Test") + armed: Settings.ready && !root.busy + onAct: root.testNotification() + } + } +} diff --git a/ryoku/hub/quickshell/pages/GpuPage.qml b/ryoku/hub/quickshell/pages/GpuPage.qml index d062694555..ea5df548d3 100644 --- a/ryoku/hub/quickshell/pages/GpuPage.qml +++ b/ryoku/hub/quickshell/pages/GpuPage.qml @@ -984,9 +984,12 @@ done // ── BATTERY ── SettingCard { width: gfxCol.colWidth - visible: pg.batteryTune.length > 0 title: I18n.tr("BATTERY") + BatteryAlertsSettings { + width: parent.width + } + Repeater { model: pg.batteryTune delegate: TuneCell { diff --git a/ryoku/shell/CHANGELOG.md b/ryoku/shell/CHANGELOG.md index 58711f68f9..843fbb8dab 100644 --- a/ryoku/shell/CHANGELOG.md +++ b/ryoku/shell/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased ### Added +- **Native battery alerts.** Every bar style warns once per discharge session at + configurable warning and critical levels (25% and 10% by default). Configure + or test them in Ryoku Settings → Graphics & Power → Battery. Alerts respect + Do Not Disturb and retry notification delivery failures. - **One dock for every bar style, in any design.** The dock is no longer tied to a bar style: pick the Ryoku, Python or Shima dock (or none) and it runs the same under QS Bar, Kairos, Shima, Python, Sumi and Nomarchy, on both diff --git a/ryoku/shell/ipc/batteryalerts.go b/ryoku/shell/ipc/batteryalerts.go new file mode 100644 index 0000000000..0533e304cd --- /dev/null +++ b/ryoku/shell/ipc/batteryalerts.go @@ -0,0 +1,39 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" +) + +type batteryAlertsSettings struct { + Enabled bool `json:"enabled"` + WarningPercent int `json:"warningPercent"` + CriticalPercent int `json:"criticalPercent"` +} + +func (b *batteryAlertsSettings) normalize(v *validator) { + v.rangeI("batteryAlerts.warningPercent", &b.WarningPercent, 5, 50) + v.rangeI("batteryAlerts.criticalPercent", &b.CriticalPercent, 1, 49) + if v.err != nil || b.CriticalPercent < b.WarningPercent { + return + } + if v.strict { + v.err = fmt.Errorf("batteryAlerts.criticalPercent: must be below warningPercent") + return + } + b.CriticalPercent = b.WarningPercent - 1 +} + +var batteryAlertsTestIPC = func() string { + return ipcCall("shell", "battery-alerts-native", "test", "") +} + +func (d *daemon) registerBatteryAlertsCalls() { + d.registerCall("battery-alerts.test", func(json.RawMessage) (any, error) { + if result := batteryAlertsTestIPC(); result != "ok" { + return nil, fmt.Errorf("%s", strings.TrimPrefix(result, "err ")) + } + return nil, nil + }) +} diff --git a/ryoku/shell/ipc/batteryalerts_test.go b/ryoku/shell/ipc/batteryalerts_test.go new file mode 100644 index 0000000000..73acf602c4 --- /dev/null +++ b/ryoku/shell/ipc/batteryalerts_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestBatteryAlertsDefaultsAndPersistence(t *testing.T) { + s := newTestStore(t) + want := batteryAlertsSettings{Enabled: true, WarningPercent: 25, CriticalPercent: 10} + if s.cur.BatteryAlerts != want { + t.Fatalf("defaults = %+v, want %+v", s.cur.BatteryAlerts, want) + } + for path, value := range map[string]string{"batteryAlerts.enabled": "false", "batteryAlerts.warningPercent": "30", "batteryAlerts.criticalPercent": "12"} { + if err := s.patch(path, rm(value)); err != nil { + t.Fatal(err) + } + } + want = batteryAlertsSettings{Enabled: false, WarningPercent: 30, CriticalPercent: 12} + if got := newSettingsStore(s.path).cur.BatteryAlerts; got != want { + t.Fatalf("reloaded = %+v, want %+v", got, want) + } + if err := s.reset("batteryAlerts"); err != nil { + t.Fatal(err) + } + if s.cur.BatteryAlerts != defaultSettings().BatteryAlerts { + t.Fatalf("reset = %+v", s.cur.BatteryAlerts) + } +} + +func TestBatteryAlertsRejectsInvalidPatchWithoutMutation(t *testing.T) { + for _, tc := range []struct{ path, value string }{ + {"enabled", "1"}, {"warningPercent", "4"}, {"warningPercent", "51"}, + {"criticalPercent", "0"}, {"criticalPercent", "50"}, + {"criticalPercent", "25"}, {"warningPercent", "10"}, {"unknown", "1"}, + } { + t.Run(tc.path+"="+tc.value, func(t *testing.T) { + s := newTestStore(t) + before := s.frameLocked() + if err := s.patch("batteryAlerts."+tc.path, rm(tc.value)); err == nil { + t.Fatal("invalid patch accepted") + } + if !reflect.DeepEqual(before, s.frameLocked()) { + t.Fatal("rejected patch mutated settings") + } + if _, err := os.Stat(s.path); !os.IsNotExist(err) { + t.Fatalf("rejected patch persisted: %v", err) + } + }) + } +} + +func TestBatteryAlertsLenientLoadNormalizesThresholds(t *testing.T) { + for _, tc := range []struct{ warning, critical, wantWarning, wantCritical int }{ + {0, 90, 5, 4}, {90, -1, 50, 1}, {15, 20, 15, 14}, {5, 1, 5, 1}, {50, 49, 50, 49}, + } { + s, err := buildSettings(map[string]any{"batteryAlerts": map[string]any{"warningPercent": tc.warning, "criticalPercent": tc.critical}}, false) + if err != nil { + t.Fatal(err) + } + if s.BatteryAlerts.WarningPercent != tc.wantWarning || s.BatteryAlerts.CriticalPercent != tc.wantCritical || !s.BatteryAlerts.Enabled { + t.Fatalf("load (%d,%d) = %+v", tc.warning, tc.critical, s.BatteryAlerts) + } + } +} + +func TestBatteryAlertsTestRPC(t *testing.T) { + previous := batteryAlertsTestIPC + t.Cleanup(func() { batteryAlertsTestIPC = previous }) + called := 0 + batteryAlertsTestIPC = func() string { called++; return "ok" } + d := &daemon{} + d.registerBatteryAlertsCalls() + fn := d.callHandler("battery-alerts.test") + if fn == nil { + t.Fatal("test call not registered") + } + if _, err := fn(rm(`{}`)); err != nil || called != 1 { + t.Fatalf("forwarding: calls=%d, err=%v", called, err) + } + batteryAlertsTestIPC = func() string { return "err qs ipc shell/test: unavailable" } + if _, err := fn(rm(`{}`)); err == nil || !strings.Contains(err.Error(), "unavailable") { + t.Fatalf("forward failure = %v", err) + } +} + +func TestBatteryAlertsTestIPCUsesNativeTarget(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "argv") + if err := os.WriteFile(filepath.Join(dir, "qs"), []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$BATTERY_ALERTS_ARGV\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("BATTERY_ALERTS_ARGV", logPath) + previous := shellDir + shellDir = "" + t.Cleanup(func() { shellDir = previous }) + if got := batteryAlertsTestIPC(); got != "ok" { + t.Fatal(got) + } + argv, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if string(argv) != "-c\nshell\nipc\ncall\nbattery-alerts-native\ntest\n" { + t.Fatalf("unexpected IPC argv: %q", argv) + } +} diff --git a/ryoku/shell/ipc/settings.go b/ryoku/shell/ipc/settings.go index d5c5217907..9873ae76cf 100644 --- a/ryoku/shell/ipc/settings.go +++ b/ryoku/shell/ipc/settings.go @@ -53,6 +53,7 @@ var contractKeys = map[string]bool{ "wallpaper": true, "ask": true, "controls": true, + "batteryAlerts": true, } // Value domains, in the display order of contract 14 section 8. Stored as stable @@ -122,6 +123,7 @@ type settings struct { Wallpaper wallpaperSettings `json:"wallpaper"` Ask askSettings `json:"ask"` Controls controlsSettings `json:"controls"` + BatteryAlerts batteryAlertsSettings `json:"batteryAlerts"` } type generalSettings struct { @@ -362,6 +364,7 @@ func defaultSettings() *settings { Wallpaper: wallpaperSettings{ContentFit: "Cover", TransitionPreset: "random", VideoEngine: "ryogami", VideoEnabled: true, VideoTranscodeFps: 24, VideoTranscodeWidth: 1920}, Ask: askSettings{Bubble: askBubbleSettings{Enabled: false, X: 0.94, Y: 0.68, Screen: ""}}, Controls: controlsSettings{Sections: controls, Hidden: []string{}}, + BatteryAlerts: batteryAlertsSettings{Enabled: true, WarningPercent: 25, CriticalPercent: 10}, } } @@ -445,6 +448,7 @@ func (s *settings) normalize(strict bool) error { s.Wallpaper.normalize(v) s.Ask.normalize(v) s.Controls.normalize() + s.BatteryAlerts.normalize(v) return v.err } @@ -801,6 +805,7 @@ func buildSettings(raw map[string]any, strict bool) (*settings, error) { "wallpaper": &s.Wallpaper, "ask": &s.Ask, "controls": &s.Controls, + "batteryAlerts": &s.BatteryAlerts, } for k, ptr := range dst { raw, ok := raw[k] @@ -1265,6 +1270,7 @@ func (d *daemon) startSettings() { // hardcode). Off the hot path, best-effort. go applyFont(frame) + d.registerBatteryAlertsCalls() d.registerCall("settings.patch", func(raw json.RawMessage) (any, error) { var a struct { Path string `json:"path"` diff --git a/ryoku/shell/ipc/settings_test.go b/ryoku/shell/ipc/settings_test.go index 9ee4f33f6a..3aed22697c 100644 --- a/ryoku/shell/ipc/settings_test.go +++ b/ryoku/shell/ipc/settings_test.go @@ -372,11 +372,11 @@ func TestMalformedFileFirstLoad(t *testing.T) { if !reflect.DeepEqual(s.cur, defaultSettings()) { t.Fatalf("unparseable file did not fall back to defaults") } - // Defaults only: exactly the eight schema namespaces, no passthrough. - if len(s.raw) != 8 { - t.Fatalf("first-load fallback carried %d top-level keys, want 8 (schema only)", len(s.raw)) + // Defaults only: exactly the schema namespaces, no passthrough. + if len(s.raw) != len(contractKeys) { + t.Fatalf("first-load fallback carried %d top-level keys, want %d (schema only)", len(s.raw), len(contractKeys)) } - for _, k := range []string{"general", "theme", "bars", "menus", "notifications", "wallpaper", "ask", "controls"} { + for _, k := range []string{"general", "theme", "bars", "menus", "notifications", "wallpaper", "ask", "controls", "batteryAlerts"} { if _, ok := s.raw[k]; !ok { t.Fatalf("first-load fallback missing schema namespace %q", k) } diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/python/singletons/system/SysNotif.qml b/ryoku/shell/quickshell/shell/modules/bar/barstyles/python/singletons/system/SysNotif.qml index b269cf7e8e..24b8bcae72 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/python/singletons/system/SysNotif.qml +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/python/singletons/system/SysNotif.qml @@ -12,8 +12,6 @@ Item { readonly property bool isCharging: UPower.displayDevice.ready && (UPower.displayDevice.state === UPowerDeviceState.Charging || UPower.displayDevice.state === UPowerDeviceState.FullyCharged) property bool notifiedFull: false - property bool notifiedLow: false - property bool notifiedCritical: false property real lastNotifTime: 0 property string lastNotifType: "" @@ -55,11 +53,6 @@ Item { } if (charging) { - if (pct > 20) { - root.notifiedLow = false; - root.notifiedCritical = false; - } - if ((pct >= 100 || state === UPowerDeviceState.FullyCharged) && !root.notifiedFull) { root.notifiedFull = true; root.sendNotification( @@ -70,35 +63,8 @@ Item { "normal" ); } - } else { - if (pct <= 5) { - if (!root.notifiedCritical) { - root.notifiedCritical = true; - root.notifiedLow = true; - root.sendNotification( - "critical", - I18n.t("sysnotif.battery.critical_title"), - I18n.t("sysnotif.battery.critical_body", { "pct": pct.toString() }), - "battery-level-0-symbolic", - "critical" - ); - } - } else if (pct <= 20) { - if (!root.notifiedLow) { - root.notifiedLow = true; - root.sendNotification( - "low", - I18n.t("sysnotif.battery.low_title"), - I18n.t("sysnotif.battery.low_body", { "pct": pct.toString() }), - "battery-level-20-symbolic", - "critical" - ); - } - } else if (pct > 25) { - root.notifiedLow = false; - root.notifiedCritical = false; - } } + // Discharge alerts are owned by the global BatteryAlerts service. } Connections { diff --git a/ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js b/ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js new file mode 100644 index 0000000000..2d2ddf3754 --- /dev/null +++ b/ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js @@ -0,0 +1,18 @@ +// The live QML service and regression tests use this same policy. +function initialState() { return { lowSent: false, criticalSent: false }; } +function evaluate(state, sample, settings) { + var next = { lowSent: state.lowSent, criticalSent: state.criticalSent }; + if (sample.onAc) return { state: initialState(), level: "" }; + if (!settings.enabled || !sample.present || !sample.ready || !sample.discharging + || !Number.isFinite(sample.percent) || sample.percent < 0 || sample.percent > 100) + return { state: next, level: "" }; + if (sample.percent <= settings.critical && !next.criticalSent) { + next.lowSent = true; next.criticalSent = true; + return { state: next, level: "critical" }; + } + if (sample.percent <= settings.low && !next.lowSent) { + next.lowSent = true; + return { state: next, level: "low" }; + } + return { state: next, level: "" }; +} diff --git a/ryoku/shell/quickshell/shell/services/BatteryAlerts.qml b/ryoku/shell/quickshell/shell/services/BatteryAlerts.qml new file mode 100644 index 0000000000..42f9e77bce --- /dev/null +++ b/ryoku/shell/quickshell/shell/services/BatteryAlerts.qml @@ -0,0 +1,101 @@ +pragma Singleton +pragma ComponentBehavior: Bound +import QtQuick +import Quickshell +import Quickshell.Io +import Ryoku.Ui.Singletons +import "BatteryAlertPolicy.js" as Policy + +// One monitor per QML engine, even with several physical displays. +Singleton { + id: svc + readonly property var settings: Config.batteryAlerts + readonly property bool enabled: settings.enabled ?? true + readonly property int lowThreshold: Math.max(5, Math.min(50, Number(settings.warningPercent ?? 25))) + readonly property int criticalThreshold: Math.max(1, Math.min(lowThreshold - 1, Number(settings.criticalPercent ?? 10))) + readonly property bool present: Battery.present + readonly property bool ready: present && Battery.batDev.ready + readonly property bool onAc: Battery.onAc + readonly property bool discharging: Battery.discharging + readonly property real percent: ready ? Battery.frac * 100 : NaN + readonly property int percentage: Number.isFinite(percent) ? Math.round(percent) : -1 + property string lastResult: "" + property var alertState: Policy.initialState() + property var pendingState: null + property int session: 0 + property int pendingSession: 0 + property bool testing: false + + // The Hub tests the same notification path without changing discharge state. + IpcHandler { + target: "battery-alerts-native" + function test(): void { svc.testNotification(); } + function status(): string { + return JSON.stringify({ percent: svc.percentage, + enabled: svc.enabled, onAc: svc.onAc, ready: svc.ready, + warning: svc.lowThreshold, critical: svc.criticalThreshold, + lastResult: svc.lastResult }); + } + } + + function evaluate() { + if (onAc) { alertState = Policy.initialState(); return; } + if (notifier.running) return; + var result = Policy.evaluate(alertState, + { present: present, ready: ready, onAc: onAc, discharging: discharging, percent: percent }, + { enabled: enabled, low: lowThreshold, critical: criticalThreshold }); + if (!result.level) return; + pendingState = result.state; + pendingSession = session; + testing = false; + send(result.level === "critical" ? I18n.tr("Battery critical") : I18n.tr("Battery low"), + I18n.tr("Battery at %1%. Connect your charger.").arg(percentage), result.level === "critical"); + } + function send(title, body, critical) { + notifier.command = ["notify-send", "--app-name=Battery Alerts", "--icon=battery-caution-symbolic", + critical ? "--urgency=critical" : "--urgency=normal", title, body]; + lastResult = I18n.tr("Sending notification…"); + notifier.running = true; + } + function testNotification() { + if (notifier.running) return; + testing = true; + pendingState = null; + send(I18n.tr("Battery Alerts test"), + I18n.tr("Low-battery alerts are ready. Warning: %1%. Critical: %2%.").arg(lowThreshold).arg(criticalThreshold), false); + } + onOnAcChanged: { + session++; + if (onAc) alertState = Policy.initialState(); + soon.restart(); + } + onPercentChanged: soon.restart() + onReadyChanged: soon.restart() + onDischargingChanged: soon.restart() + onSettingsChanged: soon.restart() + onEnabledChanged: soon.restart() + Component.onCompleted: soon.restart() + Timer { id: soon; interval: 300; onTriggered: svc.evaluate() } + // Alerts stay alive while their panel is closed or bar hidden. + Timer { + interval: 30000 + running: svc.enabled && svc.present && !svc.onAc + repeat: true + onTriggered: svc.evaluate() + } + Process { + id: notifier + onExited: (code) => { + if (code === 0) { + if (!svc.testing && svc.pendingState && svc.pendingSession === svc.session && !svc.onAc) + svc.alertState = svc.pendingState; + svc.lastResult = I18n.tr("Notification sent."); + } else { + svc.lastResult = I18n.tr("Notification failed. Check notify-send and the notification service."); + } + svc.pendingState = null; + // The periodic timer retries failures; avoid an immediate retry loop. + if (code === 0) soon.restart(); + } + } +} diff --git a/ryoku/shell/quickshell/shell/services/Config.qml b/ryoku/shell/quickshell/shell/services/Config.qml index 2adcc67608..07ab803c55 100644 --- a/ryoku/shell/quickshell/shell/services/Config.qml +++ b/ryoku/shell/quickshell/shell/services/Config.qml @@ -37,6 +37,7 @@ Singleton { // (a shipped folder style under modules/bar/barstyles/qsbar); "sumi" is the // built-in painted left rail; any other id is an installed store folder // style. Each owns its own bar, popouts and settings. Default qsbar. + property alias batteryAlerts: adapter.batteryAlerts property alias barStyle: adapter.barStyle // obi: per-widget visibility for the Obi bar style, edited in Bar Studio. @@ -258,6 +259,7 @@ Singleton { property string formatLocale: "" property string screenShader: "" property var frameBars: FrameBars.defaultConfig() + property var batteryAlerts: ({ enabled: true, warningPercent: 25, criticalPercent: 10 }) property string barStyle: "qsbar" property string launcherTarget: "studio" property var obi: ({}) diff --git a/ryoku/shell/quickshell/shell/services/qmldir b/ryoku/shell/quickshell/shell/services/qmldir index 538a63f511..be004fa6d6 100644 --- a/ryoku/shell/quickshell/shell/services/qmldir +++ b/ryoku/shell/quickshell/shell/services/qmldir @@ -50,3 +50,4 @@ singleton Needle Needle.qml singleton StatsFeed StatsFeed.qml singleton AppLaunch AppLaunch.qml singleton Icons Icons.qml +singleton BatteryAlerts BatteryAlerts.qml diff --git a/ryoku/shell/quickshell/shell/shell.qml b/ryoku/shell/quickshell/shell/shell.qml index e2598a9b36..199a2e817e 100644 --- a/ryoku/shell/quickshell/shell/shell.qml +++ b/ryoku/shell/quickshell/shell/shell.qml @@ -69,7 +69,7 @@ ShellRoot { // Construct the shared services (ShellState's per-monitor state now, heavier // providers as surfaces migrate) at load rather than on the first keybind. ServiceLoader { - services: [ShellState, ScreenTime, Keypresses, KeyboardLayout] + services: [ShellState, ScreenTime, Keypresses, KeyboardLayout, BatteryAlerts] } readonly property string reloadStatePath: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/ryoku-reload-cover.json" diff --git a/tests/ui/battery-alerts-probe.qml b/tests/ui/battery-alerts-probe.qml new file mode 100644 index 0000000000..99e300a300 --- /dev/null +++ b/tests/ui/battery-alerts-probe.qml @@ -0,0 +1,76 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "services" +ShellRoot { + Process { id: fixture } + property int phase: 0 + function check(ok, label) { if (!ok) throw new Error(label); } + Timer { + interval: 700 + running: true + repeat: true + onTriggered: { + switch (phase++) { + case 0: + check(BatteryAlerts.alertState.lowSent && !BatteryAlerts.alertState.criticalSent, "warning delivery"); + Battery.frac = 0.24; + break; + case 1: + check(BatteryAlerts.alertState.lowSent, "warning deduplication"); + Battery.frac = 0.10; + break; + case 2: + check(BatteryAlerts.alertState.criticalSent, "critical delivery"); + Battery.onAc = true; + break; + case 3: + check(!BatteryAlerts.alertState.lowSent && !BatteryAlerts.alertState.criticalSent, "AC reset"); + Config.batteryAlerts = { enabled: false, warningPercent: 30, criticalPercent: 12 }; + Battery.onAc = false; + break; + case 4: + check(!BatteryAlerts.alertState.lowSent, "disabled alerts"); + Config.batteryAlerts = { enabled: true, warningPercent: 30, criticalPercent: 12 }; + break; + case 5: + check(BatteryAlerts.alertState.lowSent && BatteryAlerts.alertState.criticalSent, "critical startup skips warning"); + BatteryAlerts.testNotification(); + break; + case 6: + check(BatteryAlerts.lastResult === "Notification sent.", "test delivery"); + check(BatteryAlerts.alertState.criticalSent, "test preserves discharge state"); + Battery.onAc = true; + break; + case 7: + Battery.frac = 0.30; + Battery.onAc = false; + break; + case 8: + check(BatteryAlerts.alertState.lowSent && !BatteryAlerts.alertState.criticalSent, "custom threshold"); + Battery.onAc = true; + fixture.command = ["touch", Quickshell.env("BATTERY_PROBE_FAIL")]; + fixture.running = true; + break; + case 9: + Battery.frac = 0.10; + Battery.onAc = false; + break; + case 10: + check(!BatteryAlerts.alertState.criticalSent, "failed delivery does not consume alert"); + check(BatteryAlerts.lastResult.indexOf("failed") >= 0, "failure reported"); + fixture.command = ["rm", Quickshell.env("BATTERY_PROBE_FAIL")]; + fixture.running = true; + break; + case 11: + BatteryAlerts.evaluate(); + break; + case 12: + check(BatteryAlerts.alertState.criticalSent, "failed delivery can retry"); + console.log("BATTERY-ALERTS-PROBE-PASS"); + Qt.quit(); + } + } + } + Component.onCompleted: { void BatteryAlerts.enabled; } +} diff --git a/tests/ui/battery-alerts-probe.sh b/tests/ui/battery-alerts-probe.sh new file mode 100755 index 0000000000..5bc9e57498 --- /dev/null +++ b/tests/ui/battery-alerts-probe.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +repo="$here/../.." +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/services" "$work/Ryoku/Ui/Singletons" "$work/bin" "$work/run" +chmod 700 "$work/run" +cp "$repo/ryoku/shell/quickshell/shell/services/"{BatteryAlerts.qml,BatteryAlertPolicy.js} "$work/services/" +cat > "$work/services/qmldir" <<'QML' +singleton BatteryAlerts BatteryAlerts.qml +singleton Battery Battery.qml +singleton Config Config.qml +QML +cat > "$work/services/Battery.qml" <<'QML' +pragma Singleton +import QtQuick +QtObject { + property bool present: true + property var batDev: ({ ready: true }) + property bool onAc: false + property bool discharging: true + property real frac: 0.25 +} +QML +cat > "$work/services/Config.qml" <<'QML' +pragma Singleton +import QtQuick +QtObject { property var batteryAlerts: ({ enabled: true, warningPercent: 25, criticalPercent: 10 }) } +QML +cat > "$work/Ryoku/Ui/Singletons/qmldir" <<'QML' +module Ryoku.Ui.Singletons +singleton I18n I18n.qml +QML +cat > "$work/Ryoku/Ui/Singletons/I18n.qml" <<'QML' +pragma Singleton +import QtQuick +QtObject { function tr(text) { return text; } } +QML +cat > "$work/bin/notify-send" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$BATTERY_PROBE_LOG" +[[ ! -e "$BATTERY_PROBE_FAIL" ]] +SH +chmod +x "$work/bin/notify-send" +cp "$here/battery-alerts-probe.qml" "$work/probe.qml" +env PATH="$work/bin:$PATH" BATTERY_PROBE_LOG="$work/notifications" BATTERY_PROBE_FAIL="$work/fail" \ + XDG_RUNTIME_DIR="$work/run" XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \ + XDG_CACHE_HOME="$work/cache" QT_QPA_PLATFORM=offscreen QML2_IMPORT_PATH="$work" \ + timeout 20 qs -p "$work/probe.qml" > "$work/log" 2>&1 || true +if ! rg -q BATTERY-ALERTS-PROBE-PASS "$work/log"; then + cat "$work/log"; exit 1 +fi +[[ $(wc -l < "$work/notifications") -eq 7 ]] +rg -q -- '--urgency=normal Battery low Battery at 25%' "$work/notifications" +rg -q -- '--urgency=critical Battery critical Battery at 10%' "$work/notifications" +echo 'battery-alerts-probe: native delivery, discharge reset, deduplication, settings, critical startup and failure retry pass' From 16e507dbd40baeb2b81b9b5617e7cba4bf99c604 Mon Sep 17 00:00:00 2001 From: rayyan Date: Fri, 9 Oct 2026 22:02:15 -0300 Subject: [PATCH 2/3] [global] power: allow warning-only and critical-only battery alerts Note: New: Enable warning and critical battery reminders separately in Battery settings --- docs/power.md | 5 ++- ryoku/hub/CHANGELOG.md | 5 ++- .../pages/BatteryAlertsSettings.qml | 44 +++++++++++++++++-- ryoku/shell/CHANGELOG.md | 2 + ryoku/shell/ipc/batteryalerts.go | 2 + ryoku/shell/ipc/batteryalerts_test.go | 22 ++++++++-- ryoku/shell/ipc/settings.go | 2 +- .../shell/services/BatteryAlertPolicy.js | 4 +- .../shell/services/BatteryAlerts.qml | 18 ++++++-- .../quickshell/shell/services/Config.qml | 2 +- tests/ui/battery-alerts-probe.qml | 37 ++++++++++++++++ tests/ui/battery-alerts-probe.sh | 7 ++- 12 files changed, 130 insertions(+), 20 deletions(-) diff --git a/docs/power.md b/docs/power.md index 86a509c268..75705751bb 100644 --- a/docs/power.md +++ b/docs/power.md @@ -12,8 +12,9 @@ chassis; the *shape* of the conclusions holds for AMD laptops generally. ## Low-battery alerts Open **Win+, → Graphics & Power → Battery** to enable alerts, set the warning -and critical percentages, or send a test notification. Defaults are 25% and -10%. Warning accepts 5–50%; critical accepts 1–49% and must be below warning. +and critical percentages, enable either level independently, or send a test notification. Defaults are 25% and +10%. Both levels default to enabled; turn either one off for warning-only or +critical-only reminders. Warning accepts 5–50%; critical accepts 1–49% and must be below warning. These settings are independent of hardware support for a charge ceiling. While discharging, Ryoku sends “Battery low” at the warning level and “Battery diff --git a/ryoku/hub/CHANGELOG.md b/ryoku/hub/CHANGELOG.md index 260685ea18..6685abbc18 100644 --- a/ryoku/hub/CHANGELOG.md +++ b/ryoku/hub/CHANGELOG.md @@ -3,8 +3,9 @@ ## Unreleased ### Added -- Battery settings include low-battery alerts, warning and critical percentages, - and a test notification. Changes apply immediately and critical always stays +- Battery settings include low-battery alerts, independent warning and critical + switches and percentages, and a test notification. Either reminder can run + alone. Changes apply immediately and critical always stays below warning (`quickshell/pages/BatteryAlertsSettings.qml`). ### Changed diff --git a/ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml b/ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml index f087ba77cb..570900f8c0 100644 --- a/ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml +++ b/ryoku/hub/quickshell/pages/BatteryAlertsSettings.qml @@ -14,6 +14,14 @@ Column { Settings.revision; return Settings.get("batteryAlerts.enabled") !== false; } + readonly property bool warningEnabled: { + Settings.revision; + return Settings.get("batteryAlerts.warningEnabled") !== false; + } + readonly property bool criticalEnabled: { + Settings.revision; + return Settings.get("batteryAlerts.criticalEnabled") !== false; + } readonly property int warningPercent: { Settings.revision; var value = Settings.get("batteryAlerts.warningPercent"); @@ -57,6 +65,21 @@ Column { } } + SettingRow { + width: parent.width + divider: true + label: I18n.tr("Warning alerts") + desc: I18n.tr("Send the first reminder to plug in.") + controlWidth: 54 + enabled: Settings.ready && root.alertsEnabled && !root.busy + Sw { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + on: root.warningEnabled + onToggled: value => root.save("warningEnabled", value) + } + } + SettingRow { width: parent.width divider: true @@ -66,7 +89,7 @@ Column { unit: "%" value: String(root.warningPercent) editableValue: true - enabled: Settings.ready && root.alertsEnabled && !root.busy + enabled: Settings.ready && root.alertsEnabled && root.warningEnabled && !root.busy onValueCommitted: text => { var value = Number(text); if (text.trim() !== "" && isFinite(value)) @@ -83,6 +106,21 @@ Column { } } + SettingRow { + width: parent.width + divider: true + label: I18n.tr("Critical alerts") + desc: I18n.tr("Send the urgent reminder to plug in.") + controlWidth: 54 + enabled: Settings.ready && root.alertsEnabled && !root.busy + Sw { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + on: root.criticalEnabled + onToggled: value => root.save("criticalEnabled", value) + } + } + SettingRow { width: parent.width divider: true @@ -92,7 +130,7 @@ Column { unit: "%" value: String(root.criticalPercent) editableValue: true - enabled: Settings.ready && root.alertsEnabled && !root.busy + enabled: Settings.ready && root.alertsEnabled && root.criticalEnabled && !root.busy onValueCommitted: text => { var value = Number(text); if (text.trim() !== "" && isFinite(value)) @@ -120,7 +158,7 @@ Column { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: I18n.tr("Test") - armed: Settings.ready && !root.busy + armed: Settings.ready && root.alertsEnabled && !root.busy onAct: root.testNotification() } } diff --git a/ryoku/shell/CHANGELOG.md b/ryoku/shell/CHANGELOG.md index 843fbb8dab..3c183fc7c2 100644 --- a/ryoku/shell/CHANGELOG.md +++ b/ryoku/shell/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased ### Added +- Warning and critical battery alerts can each be enabled independently in + Graphics & Power → Battery, including warning-only or critical-only modes. - **Native battery alerts.** Every bar style warns once per discharge session at configurable warning and critical levels (25% and 10% by default). Configure or test them in Ryoku Settings → Graphics & Power → Battery. Alerts respect diff --git a/ryoku/shell/ipc/batteryalerts.go b/ryoku/shell/ipc/batteryalerts.go index 0533e304cd..e5f9468562 100644 --- a/ryoku/shell/ipc/batteryalerts.go +++ b/ryoku/shell/ipc/batteryalerts.go @@ -7,6 +7,8 @@ import ( ) type batteryAlertsSettings struct { + WarningEnabled bool `json:"warningEnabled"` + CriticalEnabled bool `json:"criticalEnabled"` Enabled bool `json:"enabled"` WarningPercent int `json:"warningPercent"` CriticalPercent int `json:"criticalPercent"` diff --git a/ryoku/shell/ipc/batteryalerts_test.go b/ryoku/shell/ipc/batteryalerts_test.go index 73acf602c4..63a24d312b 100644 --- a/ryoku/shell/ipc/batteryalerts_test.go +++ b/ryoku/shell/ipc/batteryalerts_test.go @@ -10,7 +10,7 @@ import ( func TestBatteryAlertsDefaultsAndPersistence(t *testing.T) { s := newTestStore(t) - want := batteryAlertsSettings{Enabled: true, WarningPercent: 25, CriticalPercent: 10} + want := batteryAlertsSettings{WarningEnabled: true, CriticalEnabled: true, Enabled: true, WarningPercent: 25, CriticalPercent: 10} if s.cur.BatteryAlerts != want { t.Fatalf("defaults = %+v, want %+v", s.cur.BatteryAlerts, want) } @@ -19,7 +19,7 @@ func TestBatteryAlertsDefaultsAndPersistence(t *testing.T) { t.Fatal(err) } } - want = batteryAlertsSettings{Enabled: false, WarningPercent: 30, CriticalPercent: 12} + want = batteryAlertsSettings{WarningEnabled: true, CriticalEnabled: true, Enabled: false, WarningPercent: 30, CriticalPercent: 12} if got := newSettingsStore(s.path).cur.BatteryAlerts; got != want { t.Fatalf("reloaded = %+v, want %+v", got, want) } @@ -33,7 +33,7 @@ func TestBatteryAlertsDefaultsAndPersistence(t *testing.T) { func TestBatteryAlertsRejectsInvalidPatchWithoutMutation(t *testing.T) { for _, tc := range []struct{ path, value string }{ - {"enabled", "1"}, {"warningPercent", "4"}, {"warningPercent", "51"}, + {"warningEnabled", "1"}, {"criticalEnabled", "1"}, {"enabled", "1"}, {"warningPercent", "4"}, {"warningPercent", "51"}, {"criticalPercent", "0"}, {"criticalPercent", "50"}, {"criticalPercent", "25"}, {"warningPercent", "10"}, {"unknown", "1"}, } { @@ -109,3 +109,19 @@ func TestBatteryAlertsTestIPCUsesNativeTarget(t *testing.T) { t.Fatalf("unexpected IPC argv: %q", argv) } } + +func TestBatteryAlertsIndependentLevelsPersist(t *testing.T) { + for _, mode := range []struct{ warning, critical string }{{"true", "false"}, {"false", "true"}, {"false", "false"}, {"true", "true"}} { + s := newTestStore(t) + if err := s.patch("batteryAlerts.warningEnabled", rm(mode.warning)); err != nil { + t.Fatal(err) + } + if err := s.patch("batteryAlerts.criticalEnabled", rm(mode.critical)); err != nil { + t.Fatal(err) + } + got := newSettingsStore(s.path).cur.BatteryAlerts + if got.WarningEnabled != (mode.warning == "true") || got.CriticalEnabled != (mode.critical == "true") { + t.Fatalf("mode did not persist: %+v", got) + } + } +} diff --git a/ryoku/shell/ipc/settings.go b/ryoku/shell/ipc/settings.go index 9873ae76cf..778292e04f 100644 --- a/ryoku/shell/ipc/settings.go +++ b/ryoku/shell/ipc/settings.go @@ -364,7 +364,7 @@ func defaultSettings() *settings { Wallpaper: wallpaperSettings{ContentFit: "Cover", TransitionPreset: "random", VideoEngine: "ryogami", VideoEnabled: true, VideoTranscodeFps: 24, VideoTranscodeWidth: 1920}, Ask: askSettings{Bubble: askBubbleSettings{Enabled: false, X: 0.94, Y: 0.68, Screen: ""}}, Controls: controlsSettings{Sections: controls, Hidden: []string{}}, - BatteryAlerts: batteryAlertsSettings{Enabled: true, WarningPercent: 25, CriticalPercent: 10}, + BatteryAlerts: batteryAlertsSettings{Enabled: true, WarningEnabled: true, CriticalEnabled: true, WarningPercent: 25, CriticalPercent: 10}, } } diff --git a/ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js b/ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js index 2d2ddf3754..455c3f3a75 100644 --- a/ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js +++ b/ryoku/shell/quickshell/shell/services/BatteryAlertPolicy.js @@ -6,11 +6,11 @@ function evaluate(state, sample, settings) { if (!settings.enabled || !sample.present || !sample.ready || !sample.discharging || !Number.isFinite(sample.percent) || sample.percent < 0 || sample.percent > 100) return { state: next, level: "" }; - if (sample.percent <= settings.critical && !next.criticalSent) { + if (settings.criticalEnabled !== false && sample.percent <= settings.critical && !next.criticalSent) { next.lowSent = true; next.criticalSent = true; return { state: next, level: "critical" }; } - if (sample.percent <= settings.low && !next.lowSent) { + if (settings.warningEnabled !== false && sample.percent <= settings.low && !next.lowSent) { next.lowSent = true; return { state: next, level: "low" }; } diff --git a/ryoku/shell/quickshell/shell/services/BatteryAlerts.qml b/ryoku/shell/quickshell/shell/services/BatteryAlerts.qml index 42f9e77bce..3d24541bd1 100644 --- a/ryoku/shell/quickshell/shell/services/BatteryAlerts.qml +++ b/ryoku/shell/quickshell/shell/services/BatteryAlerts.qml @@ -11,6 +11,8 @@ Singleton { id: svc readonly property var settings: Config.batteryAlerts readonly property bool enabled: settings.enabled ?? true + readonly property bool warningEnabled: settings.warningEnabled ?? true + readonly property bool criticalEnabled: settings.criticalEnabled ?? true readonly property int lowThreshold: Math.max(5, Math.min(50, Number(settings.warningPercent ?? 25))) readonly property int criticalThreshold: Math.max(1, Math.min(lowThreshold - 1, Number(settings.criticalPercent ?? 10))) readonly property bool present: Battery.present @@ -32,7 +34,7 @@ Singleton { function test(): void { svc.testNotification(); } function status(): string { return JSON.stringify({ percent: svc.percentage, - enabled: svc.enabled, onAc: svc.onAc, ready: svc.ready, + enabled: svc.enabled, warningEnabled: svc.warningEnabled, criticalEnabled: svc.criticalEnabled, onAc: svc.onAc, ready: svc.ready, warning: svc.lowThreshold, critical: svc.criticalThreshold, lastResult: svc.lastResult }); } @@ -43,7 +45,7 @@ Singleton { if (notifier.running) return; var result = Policy.evaluate(alertState, { present: present, ready: ready, onAc: onAc, discharging: discharging, percent: percent }, - { enabled: enabled, low: lowThreshold, critical: criticalThreshold }); + { enabled: enabled, warningEnabled: warningEnabled, criticalEnabled: criticalEnabled, low: lowThreshold, critical: criticalThreshold }); if (!result.level) return; pendingState = result.state; pendingSession = session; @@ -61,8 +63,16 @@ Singleton { if (notifier.running) return; testing = true; pendingState = null; - send(I18n.tr("Battery Alerts test"), - I18n.tr("Low-battery alerts are ready. Warning: %1%. Critical: %2%.").arg(lowThreshold).arg(criticalThreshold), false); + var body; + if (warningEnabled && criticalEnabled) + body = I18n.tr("Low-battery alerts are ready. Warning: %1%. Critical: %2%.").arg(lowThreshold).arg(criticalThreshold); + else if (warningEnabled) + body = I18n.tr("Warning alerts: %1%. Critical alerts are disabled.").arg(lowThreshold); + else if (criticalEnabled) + body = I18n.tr("Critical alerts: %1%. Warning alerts are disabled.").arg(criticalThreshold); + else + body = I18n.tr("Both battery alert levels are disabled. Test notifications still work."); + send(I18n.tr("Battery Alerts test"), body, false); } onOnAcChanged: { session++; diff --git a/ryoku/shell/quickshell/shell/services/Config.qml b/ryoku/shell/quickshell/shell/services/Config.qml index 07ab803c55..dcb5e5c0ff 100644 --- a/ryoku/shell/quickshell/shell/services/Config.qml +++ b/ryoku/shell/quickshell/shell/services/Config.qml @@ -259,7 +259,7 @@ Singleton { property string formatLocale: "" property string screenShader: "" property var frameBars: FrameBars.defaultConfig() - property var batteryAlerts: ({ enabled: true, warningPercent: 25, criticalPercent: 10 }) + property var batteryAlerts: ({ enabled: true, warningEnabled: true, criticalEnabled: true, warningPercent: 25, criticalPercent: 10 }) property string barStyle: "qsbar" property string launcherTarget: "studio" property var obi: ({}) diff --git a/tests/ui/battery-alerts-probe.qml b/tests/ui/battery-alerts-probe.qml index 99e300a300..253ee8cc24 100644 --- a/tests/ui/battery-alerts-probe.qml +++ b/tests/ui/battery-alerts-probe.qml @@ -67,6 +67,43 @@ ShellRoot { break; case 12: check(BatteryAlerts.alertState.criticalSent, "failed delivery can retry"); + Battery.onAc = true; + Battery.frac = 0.25; + Config.batteryAlerts = { enabled: true, warningEnabled: false, criticalEnabled: true, warningPercent: 25, criticalPercent: 10 }; + break; + case 13: + Battery.onAc = false; + break; + case 14: + check(!BatteryAlerts.alertState.lowSent && !BatteryAlerts.alertState.criticalSent, "critical-only skips warning"); + Battery.frac = 0.10; + break; + case 15: + check(BatteryAlerts.alertState.criticalSent, "critical-only delivery"); + BatteryAlerts.testNotification(); + Battery.onAc = true; + Config.batteryAlerts = { enabled: true, warningEnabled: true, criticalEnabled: false, warningPercent: 25, criticalPercent: 10 }; + break; + case 16: + Battery.onAc = false; + break; + case 17: + check(BatteryAlerts.alertState.lowSent && !BatteryAlerts.alertState.criticalSent, "warning-only delivery below critical threshold"); + BatteryAlerts.testNotification(); + Battery.onAc = true; + Battery.frac = 0.01; + Config.batteryAlerts = { enabled: true, warningEnabled: false, criticalEnabled: false, warningPercent: 25, criticalPercent: 10 }; + break; + case 18: + Battery.onAc = false; + break; + case 19: + check(!BatteryAlerts.alertState.lowSent && !BatteryAlerts.alertState.criticalSent, "both levels disabled"); + BatteryAlerts.testNotification(); + break; + case 20: + check(BatteryAlerts.lastResult === "Notification sent.", "both-off test notification"); + check(!BatteryAlerts.alertState.lowSent && !BatteryAlerts.alertState.criticalSent, "test does not consume disabled levels"); console.log("BATTERY-ALERTS-PROBE-PASS"); Qt.quit(); } diff --git a/tests/ui/battery-alerts-probe.sh b/tests/ui/battery-alerts-probe.sh index 5bc9e57498..99d552bdcc 100755 --- a/tests/ui/battery-alerts-probe.sh +++ b/tests/ui/battery-alerts-probe.sh @@ -51,7 +51,10 @@ env PATH="$work/bin:$PATH" BATTERY_PROBE_LOG="$work/notifications" BATTERY_PROBE if ! rg -q BATTERY-ALERTS-PROBE-PASS "$work/log"; then cat "$work/log"; exit 1 fi -[[ $(wc -l < "$work/notifications") -eq 7 ]] +[[ $(wc -l < "$work/notifications") -eq 12 ]] rg -q -- '--urgency=normal Battery low Battery at 25%' "$work/notifications" rg -q -- '--urgency=critical Battery critical Battery at 10%' "$work/notifications" -echo 'battery-alerts-probe: native delivery, discharge reset, deduplication, settings, critical startup and failure retry pass' +rg -q 'Critical alerts: 10%. Warning alerts are disabled.' "$work/notifications" +rg -q 'Warning alerts: 25%. Critical alerts are disabled.' "$work/notifications" +rg -q 'Both battery alert levels are disabled.' "$work/notifications" +echo 'battery-alerts-probe: native delivery, discharge reset, deduplication, settings, critical startup, failure retry and independent levels pass' From 29fde63cb8654fcde7851d36ef6e5d3c28eb7d9c Mon Sep 17 00:00:00 2001 From: rayyan Date: Fri, 9 Oct 2026 23:59:16 -0300 Subject: [PATCH 3/3] [docs] shell: repair bundled Nomarchy bar documentation links --- .../modules/bar/barstyles/nomarchy/plugins/bar/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ryoku/shell/quickshell/shell/modules/bar/barstyles/nomarchy/plugins/bar/README.md b/ryoku/shell/quickshell/shell/modules/bar/barstyles/nomarchy/plugins/bar/README.md index c73aecceda..be9453fb0f 100644 --- a/ryoku/shell/quickshell/shell/modules/bar/barstyles/nomarchy/plugins/bar/README.md +++ b/ryoku/shell/quickshell/shell/modules/bar/barstyles/nomarchy/plugins/bar/README.md @@ -1,7 +1,7 @@ # Omarchy bar This is the Quickshell implementation of the Omarchy status bar. It is -shipped as a first-party plugin of [`omarchy-shell`](../../README.md), the +shipped as a first-party plugin of [`omarchy-shell`](../README.md), the long-running shell host. The bar is mounted at startup and lives inside the shell for its whole session. @@ -14,7 +14,7 @@ the shell for its whole session. ## Customizing -The bar config lives under the `bar:` key of [`~/.config/omarchy/shell.json`](../../../docs/omarchy-shell.md#shelljson). Out of the box the shell uses [`config/omarchy/shell.json`](../../../config/omarchy/shell.json). Once you customize anything via the bar gestures, `omarchy bar ...`, or by editing shell.json directly, your file is canonical — there is no deep-merge. +The bar config lives under the `bar:` key of `~/.config/omarchy/shell.json`. Out of the box the shell uses [`config/omarchy/shell.json`](../../../../../../../../nomarchy/config/omarchy/shell.json). Once you customize anything via the bar gestures, `omarchy bar ...`, or by editing shell.json directly, your file is canonical — there is no deep-merge. The bar is configured directly on the bar itself: drag empty bar space (or click-and-hold) to move the bar to another screen edge, double-left-click empty center-bar space to toggle transparency, and drag widgets to reorder them. The `omarchy bar position`, `omarchy bar transparent`, `omarchy bar move`, and `omarchy bar set` commands do the same from scripts. Enable or disable widgets with `omarchy plugin enable` and `omarchy plugin disable` (widget ids come from `omarchy plugin list`).