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
225 lines
8.2 KiB
QML
225 lines
8.2 KiB
QML
pragma Singleton
|
|
|
|
// Transition-only device monitoring. Resting state is intentionally silent.
|
|
|
|
import Quickshell
|
|
import Quickshell.Bluetooth
|
|
import Quickshell.Io
|
|
import Quickshell.Services.Pipewire
|
|
import QtQuick
|
|
import qs.config
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property bool outputInitialized: false
|
|
property bool bluetoothInitialized: false
|
|
property bool kdeInitialized: false
|
|
property bool powerInitialized: false
|
|
property bool previousAcOnline: true
|
|
property string previousOutput: ""
|
|
property string previousBluetooth: ""
|
|
property bool previousKdeReachable: false
|
|
property string previousKdeName: ""
|
|
|
|
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
|
|
|
|
readonly property string outputName: {
|
|
const sink = Pipewire.defaultAudioSink;
|
|
return sink?.description || sink?.nickname || sink?.name || "";
|
|
}
|
|
|
|
readonly property string bluetoothNames: {
|
|
const devices = Bluetooth.devices?.values ?? [];
|
|
return devices.filter(device => device.connected)
|
|
.map(device => device.name || device.address)
|
|
.sort()
|
|
.join("\u001f");
|
|
}
|
|
|
|
readonly property string kdeName: KdeConnect.preferredPhone?.name ?? ""
|
|
|
|
onOutputNameChanged: {
|
|
if (!root.outputInitialized) {
|
|
root.previousOutput = root.outputName;
|
|
return;
|
|
}
|
|
if (root.outputInitialized && root.outputName && root.outputName !== root.previousOutput) {
|
|
StatusEvents.publish({
|
|
key: "device-output",
|
|
glyph: "\u{F07E7}",
|
|
title: root.outputName,
|
|
detail: "Audio output selected",
|
|
priority: StatusEvents.ambientPriority
|
|
});
|
|
}
|
|
root.previousOutput = root.outputName;
|
|
}
|
|
|
|
onBluetoothNamesChanged: {
|
|
if (!root.bluetoothInitialized) {
|
|
root.previousBluetooth = root.bluetoothNames;
|
|
return;
|
|
}
|
|
if (root.bluetoothInitialized && root.bluetoothNames !== root.previousBluetooth) {
|
|
const current = root.bluetoothNames ? root.bluetoothNames.split("\u001f") : [];
|
|
const previous = root.previousBluetooth ? root.previousBluetooth.split("\u001f") : [];
|
|
const connected = current.find(name => !previous.includes(name));
|
|
const disconnected = previous.find(name => !current.includes(name));
|
|
StatusEvents.publish({
|
|
key: "device-bluetooth",
|
|
glyph: "\u{F00B1}",
|
|
title: connected || disconnected || "Bluetooth device",
|
|
detail: connected ? "Connected" : "Disconnected",
|
|
tone: connected ? "accent" : "warn",
|
|
priority: StatusEvents.ambientPriority
|
|
});
|
|
}
|
|
root.previousBluetooth = root.bluetoothNames;
|
|
}
|
|
|
|
Timer {
|
|
id: discoverySettle
|
|
interval: 1800
|
|
onTriggered: {
|
|
root.previousOutput = root.outputName;
|
|
root.previousBluetooth = root.bluetoothNames;
|
|
root.previousKdeReachable = KdeConnect.phoneReachable;
|
|
root.previousKdeName = root.kdeName;
|
|
root.previousAcOnline = Battery.acOnline;
|
|
root.outputInitialized = true;
|
|
root.bluetoothInitialized = true;
|
|
root.kdeInitialized = true;
|
|
root.powerInitialized = true;
|
|
}
|
|
}
|
|
|
|
// The charger. Announced the way every other device transition here is:
|
|
// once, on the change, and never as a description of the resting state.
|
|
// Ambient priority, so Do Not Disturb quiets it -- a charger is exactly
|
|
// the kind of thing DND is for.
|
|
//
|
|
// Absent entirely on a desktop, because Battery.acChanged only fires where
|
|
// there is a battery to be on.
|
|
Connections {
|
|
target: Battery
|
|
function onAcChanged(online: bool): void {
|
|
if (!root.powerInitialized) {
|
|
root.previousAcOnline = online;
|
|
return;
|
|
}
|
|
if (online === root.previousAcOnline)
|
|
return;
|
|
root.previousAcOnline = online;
|
|
StatusEvents.publish({
|
|
key: "device-power",
|
|
glyph: online ? "\u{F06A5}" : "\u{F0084}",
|
|
title: online ? "Charging" : "On battery",
|
|
detail: Battery.available ? Math.round(Battery.percent) + "%" : "",
|
|
priority: StatusEvents.ambientPriority
|
|
});
|
|
}
|
|
}
|
|
|
|
// The "Warn at" threshold from the Power page. A quiet mention, not an
|
|
// interruption: Do Not Disturb may still silence it, and the bar glyph
|
|
// turns with it. For a long time this threshold changed only the glyph's
|
|
// color, which nobody watching their work ever saw -- the first actual
|
|
// message arrived at the critical level.
|
|
Connections {
|
|
target: Battery
|
|
function onLowChanged(): void {
|
|
if (!root.powerInitialized || !Battery.low || Battery.critical)
|
|
return;
|
|
StatusEvents.publish({
|
|
key: "battery-low",
|
|
glyph: "\u{F007A}",
|
|
title: "Battery low",
|
|
detail: Math.round(Battery.percent) + "% remaining",
|
|
tone: "warning",
|
|
priority: StatusEvents.importantPriority,
|
|
actionId: "open-settings",
|
|
actionData: "power"
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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}",
|
|
title: "Battery critically low",
|
|
detail: Math.round(Battery.percent) + "% remaining",
|
|
tone: "danger",
|
|
priority: StatusEvents.criticalPriority,
|
|
actionId: "open-settings",
|
|
actionData: "power"
|
|
});
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: KdeConnect
|
|
|
|
function onPhoneReachableChanged(): void {
|
|
if (!root.kdeInitialized)
|
|
return;
|
|
const reachable = KdeConnect.phoneReachable;
|
|
if (reachable === root.previousKdeReachable)
|
|
return;
|
|
StatusEvents.publish({
|
|
key: "device-kdeconnect",
|
|
glyph: "\u{F03F2}",
|
|
title: root.kdeName || root.previousKdeName || "Phone",
|
|
detail: reachable ? "KDE Connect available" : "KDE Connect disconnected",
|
|
tone: reachable ? "accent" : "warn",
|
|
priority: StatusEvents.ambientPriority
|
|
});
|
|
root.previousKdeReachable = reachable;
|
|
root.previousKdeName = root.kdeName || root.previousKdeName;
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
root.previousOutput = root.outputName;
|
|
root.previousBluetooth = root.bluetoothNames;
|
|
root.previousKdeReachable = KdeConnect.phoneReachable;
|
|
root.previousKdeName = root.kdeName;
|
|
discoverySettle.start();
|
|
}
|
|
}
|