Give a laptop its first week: the keys, the lid, the battery's endgame

The audit's fourth tier -- what a Framework owner reaches for in the first
week and found missing.

The power button stops being an instant, unconfirmed poweroff: a shipped
logind drop-in tells the daemon to stand down and the compositor binds the
key to the power menu, the way GNOME turns it into a question. Holding it
still hard-cuts through firmware. change-settings restarts logind so the
change applies without waiting for a boot, and the Power page says what the
button does now.

The function row fills in: F10 (XF86RFKill) toggles airplane mode through a
new panama-osd verb that blocks or unblocks every radio and says which way it
went; F9 (XF86Display) opens the Displays page, the honest action until
mirroring exists. And the lid becomes a switch bind: closing a docked lid
turns the internal panel off so nothing renders inside a closed shell and no
workspace strands on an invisible output, and opening it restores the panel
with the mode and scale chosen in Settings. panama-lid owns both decisions;
undocked machines suspend via logind before any of it matters.

The battery gets an endgame. On battery the screen dims to 30% two-thirds of
the way to blanking -- GNOME's single largest idle battery saver -- and
restores exactly the level it saved. At the urgent threshold the machine
suspends after a fifteen-second grace, cancelled by plugging in, because a
suspend preserves the session for days and a hard cut at 0% preserves
nothing; "Only warn" remains a choice on the Power page. Hibernate joins the
power menu, but only where logind answers CanHibernate with yes -- an entry
that fails silently is worse than none.

And brightness stops being two code paths: the Displays page now embeds the
same control the quick-settings panel uses, so the built-in backlight and
DDC/CI monitors share one surface that withdraws itself where neither
exists. The lid contract narrows to what its principle protects -- a
HandleLidSwitch drop-in -- so deliberate policy for other keys can ship.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
This commit is contained in:
Gabriel Brown
2026-08-23 12:21:08 -04:00
parent 153554b5df
commit 9530e32326
15 changed files with 244 additions and 35 deletions
@@ -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.
{
@@ -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",
@@ -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
}
}
@@ -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 {
+19
View File
@@ -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'
+49 -1
View File
@@ -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 ;;
+24 -1
View File
@@ -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
@@ -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}",