diff --git a/config/copy/etc/systemd/logind.conf.d/panama-power-key.conf b/config/copy/etc/systemd/logind.conf.d/panama-power-key.conf new file mode 100644 index 0000000..6acc0c3 --- /dev/null +++ b/config/copy/etc/systemd/logind.conf.d/panama-power-key.conf @@ -0,0 +1,8 @@ +# A stray press of the power button should ask, not act. Fedora's logind +# default is HandlePowerKey=poweroff -- an instant, unconfirmed shutdown. +# Panama binds the key in the compositor to open its power menu instead +# (keybinds.lua), the way GNOME turns the key into a dialog, so logind is +# told to stand down. Holding the button still hard-cuts through firmware, +# which remains the escape hatch this file cannot and should not remove. +[Login] +HandlePowerKey=ignore diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index 6e61cf9..c784cbb 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -34,6 +34,10 @@ local osd = function(action) return "$HOME/.config/quickshell/scripts/panama-osd " .. action end +local lid = function(action) + return "$HOME/.config/quickshell/scripts/panama-lid " .. action +end + local dictate = function(action) return "$HOME/.config/quickshell/scripts/panama-dictate " .. action end @@ -458,6 +462,30 @@ bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , desc bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" }) bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" }) +-- Airplane mode. The Framework's F10 emits exactly this keysym, and for a +-- while it emitted it into silence. panama-osd owns the toggle so the OSD +-- can say which way it went. +bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, description = "Airplane mode" }) + +-- F9 on the same row. GNOME shows a display-switching OSD here; until +-- mirroring exists (DESKTOP-PARITY gap), the honest action is the page where +-- displays are actually arranged. +bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" }) + +-- The power button. logind is told to ignore it (config/copy ships the +-- drop-in) so a stray press is a question, not an instant poweroff -- the +-- menu this opens is the question. Until the next boot after that drop-in +-- lands, logind still acts on the key; this bind costs nothing extra then. +bind("XF86PowerOff", hl.dsp.exec_cmd("qs ipc call powermenu toggle"), { locked = true, description = "Power menu" }) + +-- The lid, as a switch rather than a key. Closing a docked lid turns the +-- internal panel off so nothing renders inside a closed shell and no +-- workspace strands on an invisible output; opening it turns the panel back +-- on. panama-lid owns the decision -- undocked machines suspend via logind +-- before this matters, and the guard's inhibitor handles staying awake. +bind("switch:on:Lid Switch", hl.dsp.exec_cmd(lid("close")), { locked = true, description = "Lid closed" }) +bind("switch:off:Lid Switch", hl.dsp.exec_cmd(lid("open")), { locked = true, description = "Lid opened" }) + category("Applications") -- Hardware keys GNOME mapped that have obvious equivalents. bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" }) diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 336cc36..aa12d50 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -125,6 +125,20 @@ Singleton { label: "Urgent at", detail: "Interrupt at this level, even during Do Not Disturb" }, + // What the desktop DOES at the critical threshold, beyond interrupting. + // Suspend by default, which is GNOME's behavior: sleep preserves the + // session at a level the firmware can hold for days, and the + // alternative -- a hard cut at 0% -- preserves nothing. + { + key: "batteryCriticalAction", type: "enum", def: "suspend", group: "battery", + label: "At the urgent level", + detail: "What happens when the battery reaches the urgent threshold while discharging", + options: [ + { value: "suspend", label: "Suspend" }, + { value: "nothing", label: "Only warn" } + ] + }, + // Only offered where the firmware exposes a ceiling; the Power page // hides the control entirely otherwise. 100 means charge to full. { diff --git a/config/dot/quickshell/modules/powermenu/PowerMenu.qml b/config/dot/quickshell/modules/powermenu/PowerMenu.qml index fd3cac6..69945cb 100644 --- a/config/dot/quickshell/modules/powermenu/PowerMenu.qml +++ b/config/dot/quickshell/modules/powermenu/PowerMenu.qml @@ -5,6 +5,7 @@ // (see PowerButton); Lock and Suspend are one press because neither loses work. import Quickshell +import Quickshell.Io import Quickshell.Wayland import QtQuick import qs.config @@ -31,6 +32,23 @@ PanelWindow { property int currentIndex: 0 + // Hibernate appears only where it can deliver: logind answers "yes" only + // with a resume-capable swap configured, and a menu entry that fails + // silently is worse than none. Checked once per shell run -- swap does + // not come and go. + property bool canHibernate: false + + Process { + id: hibernateProbe + command: ["busctl", "call", "org.freedesktop.login1", + "/org/freedesktop/login1", "org.freedesktop.login1.Manager", + "CanHibernate"] + running: true + stdout: StdioCollector { + onStreamFinished: win.canHibernate = this.text.includes('"yes"') + } + } + // A uwsm-managed session logs out by stopping the uwsm unit; a plain // "Hyprland" session must ask the compositor to exit instead. The branch // used to test merely that the uwsm BINARY existed -- but uwsm is always @@ -41,7 +59,11 @@ PanelWindow { // compositor unit, wayland-wm@*.service, so that is what decides. readonly property string logoutScript: 'if systemctl --user list-units --no-legend --state=active "wayland-wm@*.service" 2>/dev/null | grep -q .; then exec uwsm stop; else exec hyprctl dispatch "hl.dsp.exit()"; fi' - readonly property var entries: [ + // The rows actually shown: an entry may declare `available: false` to + // withdraw itself (hibernate on a machine with no resume swap). + readonly property var entries: allEntries.filter(entry => entry.available !== false) + + readonly property var allEntries: [ { glyph: "󰌾", label: "Lock", @@ -60,6 +82,13 @@ PanelWindow { destructive: false, cmd: ["systemctl", "suspend"] }, + { + glyph: "󰋊", + label: "Hibernate", + destructive: false, + available: win.canHibernate, + cmd: ["systemctl", "hibernate"] + }, { glyph: "󰜉", label: "Restart", diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index f320710..5008202 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -12,6 +12,7 @@ import QtQuick import qs.config import qs.services +import qs.modules.quicksettings import qs.widgets SettingsPage { @@ -289,35 +290,21 @@ SettingsPage { // writing it into settings.json would mean restoring a value the panel had // already moved on from. So there is no schema key here and no SliderRow -- // the rows read and write the display directly. + // One brightness surface for both kinds this machine may have, shared + // with the quick-settings panel so the two can never disagree. This card + // was DDC/CI-only for a while, which on a laptop meant Settings showed + // an error about external-monitor brightness while the panel's backlight + // worked fine one panel over. SettingsCard { - visible: Brightness.available || Brightness.lastError !== "" + visible: pageBrightness.visible || Brightness.lastError !== "" title: "Brightness" - subtitle: Brightness.available - ? "Sent to the monitor over DDC/CI, the same channel its buttons use." + subtitle: pageBrightness.visible + ? "The built-in panel through its backlight; external monitors over DDC/CI, the same channel their buttons use." : Brightness.lastError - Repeater { - model: Brightness.displays - - SettingRow { - id: brightnessRow - required property var modelData - required property int index - - label: Displays.monitorNamed(modelData.connector)?.description || modelData.connector - detail: modelData.connector ? modelData.connector + " · " + modelData.value + "%" - : modelData.value + "%" - divider: brightnessRow.index < Brightness.displays.length - 1 - controlWidth: 190 - - ValueSlider { - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - width: parent.width - value: brightnessRow.modelData.value / 100 - onMoved: v => Brightness.set(brightnessRow.modelData.bus, Math.round(v * 100)) - } - } + BrightnessControl { + id: pageBrightness + width: parent.width } } diff --git a/config/dot/quickshell/modules/settings/PowerPage.qml b/config/dot/quickshell/modules/settings/PowerPage.qml index fb9e6e4..f174a26 100644 --- a/config/dot/quickshell/modules/settings/PowerPage.qml +++ b/config/dot/quickshell/modules/settings/PowerPage.qml @@ -53,8 +53,9 @@ SettingsPage { // page rendered them -- search delivered people to this card and the // controls were not here. SliderRow { setting: "batteryLowPercent" } - SliderRow { - setting: "batteryCriticalPercent" + SliderRow { setting: "batteryCriticalPercent" } + ChoiceRow { + setting: "batteryCriticalAction" divider: Battery.chargeLimitSupported } @@ -149,6 +150,15 @@ SettingsPage { } } + // Informational like the lid card above it: the behavior is a logind + // drop-in plus a compositor bind (see keybinds.lua), not a preference, + // and saying what a physical button does beats leaving it to be + // discovered by pressing it. + SettingsCard { + title: "The power button" + subtitle: "A press opens the power menu instead of powering off immediately. Holding it still forces power off through the firmware, as ever." + } + // Only shown when the numbers are actually contradictory, rather than as a // permanent warning nobody reads. SettingsCard { diff --git a/config/dot/quickshell/scripts/panama-idle b/config/dot/quickshell/scripts/panama-idle index 8aac71d..781c643 100755 --- a/config/dot/quickshell/scripts/panama-idle +++ b/config/dot/quickshell/scripts/panama-idle @@ -117,6 +117,25 @@ generate() { printf ' inhibit_sleep = 2\n' printf '}\n' + # On battery, dim before blanking -- GNOME's single largest idle + # battery saver, and the gentle warning that the screen is about to + # go. Two-thirds of the way to blank, floor of 30 seconds; -s saves + # the level and -r restores exactly it, so a person's brightness + # choice survives the round trip. Only where a real backlight exists: + # a desktop's DDC monitor is not worth waking over this. + if on_battery && (( blank_min > 0 )) \ + && command -v brightnessctl >/dev/null 2>&1 \ + && [[ -n "$(ls /sys/class/backlight 2>/dev/null)" ]]; then + dim_at=$(( blank_min * 60 * 2 / 3 )) + (( dim_at < 30 )) && dim_at=30 + printf '\n# %s seconds -> dim, undone on activity.\n' "$dim_at" + printf 'listener {\n' + printf ' timeout = %s\n' "$dim_at" + printf ' on-timeout = brightnessctl -c backlight -s set 30%%\n' + printf ' on-resume = brightnessctl -c backlight -r\n' + printf '}\n' + fi + if (( blank_min > 0 )); then printf '\n# %s minutes -> screen off.\n' "$blank_min" printf 'listener {\n' diff --git a/config/dot/quickshell/scripts/panama-lid b/config/dot/quickshell/scripts/panama-lid index 8d8ca23..a1ef9b2 100755 --- a/config/dot/quickshell/scripts/panama-lid +++ b/config/dot/quickshell/scripts/panama-lid @@ -68,16 +68,64 @@ cmd_guard() { sleep infinity } +# The internal panel's connector name, from the compositor: eDP, LVDS and +# DSI are built in, everything else arrived through a cable -- the same rule +# panama-hw and LidPolicy use. +internal_connector() { + hyprctl -j monitors all 2>/dev/null \ + | jq -r '[.[] | select(.name | test("^(eDP|LVDS|DSI)"; "i"))][0].name // empty' +} + +# Bound to the lid switch by keybinds.lua. Closing a DOCKED lid turns the +# internal panel off, so nothing renders inside a closed shell and no +# workspace strands on an invisible output; an undocked lid does nothing here +# because logind is already suspending the machine. Failure direction: if the +# disable never runs, the panel stays lit inside the shell -- wasteful, not +# harmful. +cmd_close() { + "$HW" laptop || exit 0 + "$HW" external-monitor || exit 0 + local internal + internal="$(internal_connector)" + [[ -n "$internal" ]] || exit 0 + hyprctl eval "hl.monitor({ output = \"$internal\", disable = true })" >/dev/null +} + +# Re-enable on open, preferring what the person chose for this panel in +# Settings (the same displays store monitors.lua reads at startup) and +# falling back to the panel's preferred mode. +cmd_open() { + "$HW" laptop || exit 0 + local internal entry mode scale + internal="$(internal_connector)" + [[ -n "$internal" ]] || exit 0 + entry="$(jq -c --arg name "$internal" '.displays[$name] // empty' \ + "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)" + if [[ -n "$entry" ]]; then + mode="$(jq -r '.mode' <<<"$entry")" + scale="$(jq -r '.scale' <<<"$entry")" + hyprctl eval "hl.monitor({ output = \"$internal\", mode = \"$mode\", position = \"auto\", scale = $scale })" >/dev/null + else + hyprctl eval "hl.monitor({ output = \"$internal\", mode = \"preferred\", position = \"auto\", scale = \"auto\" })" >/dev/null + fi +} + case "${1:-status}" in status) cmd_status ;; guard) cmd_guard ;; + close) cmd_close ;; + open) cmd_open ;; -h|--help) cat <<'USAGE' -usage: panama-lid [status|guard] +usage: panama-lid [status|guard|close|open] status what closing the lid would do right now, as JSON guard hold a handle-lid-switch inhibitor while an external display is connected; exits immediately on a machine that needs none + close docked lid closed: turn the internal panel off (bound to the lid + switch by keybinds.lua); does nothing undocked + open lid opened: turn the internal panel back on, restoring the mode + and scale chosen in Settings USAGE ;; *) printf 'panama-lid: unknown command: %s\n' "$1" >&2; exit 2 ;; diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd index d2750d0..245d9ff 100755 --- a/config/dot/quickshell/scripts/panama-osd +++ b/config/dot/quickshell/scripts/panama-osd @@ -240,11 +240,34 @@ media_action() { show_message "$kind" "$label" } +# Airplane mode: one rfkill state for every radio, with an OSD saying which +# way it went. "Any radio unblocked" reads as radios-on, so toggle means +# "block everything" from a mixed state -- the direction a person reaching +# for airplane mode wants. +airplane_action() { + case "${1:-toggle}" in + toggle) + if rfkill list 2>/dev/null | grep -q 'Soft blocked: no'; then + rfkill block all 2>/dev/null \ + && show_message "airplane-mode-symbolic" "Airplane mode on" + else + rfkill unblock all 2>/dev/null \ + && show_message "network-wireless-symbolic" "Airplane mode off" + fi + ;; + *) + printf 'Usage: panama-osd airplane toggle\n' >&2 + return 2 + ;; + esac +} + case "${1:-}" in volume) shift; adjust_volume "$@" ;; microphone) shift; adjust_microphone "$@" ;; brightness) shift; adjust_brightness "$@" ;; media) shift; media_action "$@" ;; + airplane) shift; airplane_action "$@" ;; message) shift kind="${1:-}" @@ -256,7 +279,7 @@ case "${1:-}" in show_message "$kind" "$*" ;; *) - printf 'Usage: panama-osd volume|microphone|brightness|media|message ACTION [value]\n' >&2 + printf 'Usage: panama-osd volume|microphone|brightness|media|airplane|message ACTION [value]\n' >&2 exit 2 ;; esac diff --git a/config/dot/quickshell/services/DeviceEvents.qml b/config/dot/quickshell/services/DeviceEvents.qml index 932b65f..d616a5d 100644 --- a/config/dot/quickshell/services/DeviceEvents.qml +++ b/config/dot/quickshell/services/DeviceEvents.qml @@ -7,6 +7,7 @@ import Quickshell.Bluetooth import Quickshell.Io import Quickshell.Services.Pipewire import QtQuick +import qs.config Singleton { id: root @@ -146,11 +147,38 @@ Singleton { // Running out is not ambient. Published at a priority Do Not Disturb does // not silence, because the one notification you must not miss is the one // saying the machine is about to stop. + // + // And then, unless told otherwise, the machine is put to sleep: a suspend + // at the urgent threshold preserves the session for days, a hard cut at + // 0% preserves nothing. The grace timer is what makes the warning above + // readable and the action cancelable -- plugging in during it stands + // everything down. + Timer { + id: criticalSuspend + interval: 15000 + onTriggered: { + if (Battery.critical && !Battery.acOnline + && DesktopPreferences.get("batteryCriticalAction") === "suspend") + Quickshell.execDetached(["systemctl", "suspend"]); + } + } + + Connections { + target: Battery + function onAcOnlineChanged(): void { + if (Battery.acOnline) + criticalSuspend.stop(); + } + } + Connections { target: Battery function onCriticalChanged(): void { if (!root.powerInitialized || !Battery.critical) return; + if (!Battery.acOnline + && DesktopPreferences.get("batteryCriticalAction") === "suspend") + criticalSuspend.restart(); StatusEvents.publish({ key: "battery-critical", glyph: "\u{F0083}", diff --git a/config/local/share/vicinae/scripts/settings-power b/config/local/share/vicinae/scripts/settings-power index 8391654..f8509f1 100755 --- a/config/local/share/vicinae/scripts/settings-power +++ b/config/local/share/vicinae/scripts/settings-power @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Power & Lock in Settings. -# @vicinae.keywords ["settings", "warn at", "urgent at", "stop charging at", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping", "lock after"] +# @vicinae.keywords ["settings", "warn at", "urgent at", "at the urgent level", "stop charging at", "turn the screen off after", "lock the screen after", "suspend after", "lock before sleeping", "lock after"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page power diff --git a/docs/settings.md b/docs/settings.md index f149caa..9d5aeca 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` after changing the schema; a contract fails when this copy is stale. -149 settings across 30 groups. 70 of them are applied to the compositor and confirmed by reading the value back. +150 settings across 30 groups. 70 of them are applied to the compositor and confirmed by reading the value back. ## accessibility @@ -37,6 +37,7 @@ Found on **Power & Lock**. |---|---|---| | **Warn at**
`batteryLowPercent` | 20 % | Mention the battery once it drops this low. Range 5–50. | | **Urgent at**
`batteryCriticalPercent` | 5 % | Interrupt at this level, even during Do Not Disturb. Range 1–25. | +| **At the urgent level**
`batteryCriticalAction` | suspend | What happens when the battery reaches the urgent threshold while discharging Choices: Suspend, Only warn. | | **Stop charging at**
`batteryChargeLimit` | 100 % | Charging to less than full is easier on the battery over years. Range 50–100. | ## capture diff --git a/setup/scripts/change-settings b/setup/scripts/change-settings index 094ac71..5a1e715 100755 --- a/setup/scripts/change-settings +++ b/setup/scripts/change-settings @@ -37,6 +37,16 @@ if [[ -f /etc/udev/rules.d/99-panama-gpu.rules ]]; then sudo udevadm trigger --subsystem-match=drm fi +# The power-key drop-in lands via the same copy; logind reads its config only +# at start, so without this the button keeps hard-powering-off until the next +# boot. Restarting logind is what Fedora's own updates do and current systemd +# preserves sessions across it. +if [[ -f /etc/systemd/logind.conf.d/panama-power-key.conf ]]; then + log "Reloading logind so the power button opens the menu instead of powering off" + sudo systemctl try-restart systemd-logind 2>/dev/null \ + || log "Could not restart logind; the power-button change applies at next boot" +fi + # The document-portal drop-in lands via the same copy. A user unit's drop-ins # are read at daemon-reload, so without this the guard applies from the next # login rather than from now -- and the failure it guards against is one you diff --git a/tests/quickshell/declared-dependencies-contract b/tests/quickshell/declared-dependencies-contract index d2d1649..18d1467 100755 --- a/tests/quickshell/declared-dependencies-contract +++ b/tests/quickshell/declared-dependencies-contract @@ -36,7 +36,7 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio # authselect is on the list for the same reason: it manages Fedora's PAM and # nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the # fingerprint aliases in config/bash can rely on it without declaring it. -BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof|authselect|setsid|nohup|grub2-mkconfig)$' +BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$' SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$' diff --git a/tests/setup/lid-contract b/tests/setup/lid-contract index ef4199d..7aa2fe3 100755 --- a/tests/setup/lid-contract +++ b/tests/setup/lid-contract @@ -46,8 +46,12 @@ mkdir -p "$fake/bin" # ── 1. Panama never takes the lid over ─────────────────────────────────────── -if compgen -G "$repo_dir/config/copy/etc/systemd/logind.conf.d/*" >/dev/null 2>&1; then - note 'a logind drop-in is shipped; the inhibitor approach exists so the lid is never left doing nothing' +# Specifically the LID. Other keys may be configured there -- the power-key +# drop-in is deliberate policy with a compositor bind behind it -- but a +# HandleLidSwitch line would replace the inhibitor design this file protects. +if compgen -G "$repo_dir/config/copy/etc/systemd/logind.conf.d/*" >/dev/null 2>&1 \ + && sed 's/#.*//' "$repo_dir"/config/copy/etc/systemd/logind.conf.d/* | grep -q 'HandleLidSwitch'; then + note 'a logind drop-in sets HandleLidSwitch; the inhibitor approach exists so the lid is never left doing nothing' fi # Comments are stripped first: these files explain at length what they # deliberately do NOT do, and prose naming HandleLidSwitch or lock-session is