Files
DankMaterialShell/quickshell/Modules/Settings/PowerSleepTab.qml
T
Matt Jiang f27627ead2 powermenu: reboot into a chosen EFI boot entry (#3568)
* powermenu: derive shortcuts and extra actions from one source

The power menu matched keys with a switch that repeated the key hints
getPowerActionData already returns, plus a second key list for release.
Look the pressed key up against the hints of the visible actions instead,
so every action with a hint gets its shortcut and the two cannot drift.

Side effects:
- Ctrl+letter no longer triggers actions. Only Ctrl+P was excluded
  before, so Ctrl+R still rebooted. Ctrl combinations are navigation.
- U now triggers switch user when it is shown. Its hint already said U.
- Releasing a letter with no visible action no longer cancels a hold.

SessionService now owns the custom:N action ids as customPowerActions
and extraPowerActions, which the power menu and the launcher both use.
Actions resolve by index into that list instead of slicing the prefix.

* powermenu: add actions that reboot into a chosen efi boot entry

Settings → Power & Sleep gets a "Reboot to another OS" card that lists
the firmware's active EFI entries through efibootmgr. Each saved entry
becomes a "Reboot to <name>" action in the power menu and the launcher,
bound to 1-9 in the order it was added.

Picking one lists the entries again, and stops with a toast if the id no
longer carries the saved label. Otherwise it sets BootNext through pkexec
and reboots through SessionService, so custom reboot commands still apply.

BootEntryService reports why entries are unavailable: not booted in UEFI
mode, efibootmgr missing, or efibootmgr failing with its stderr. Saved
entries stay listed in settings whatever the status, so a synced config
on another machine can still remove them.

efibootmgr parsing and the picker's filtering live in BootEntries.js,
covered by tests/boot-entries.test.mjs.

* powermenu: ignore special keys in action shortcuts
2026-09-27 11:47:05 -04:00

451 lines
19 KiB
QML

import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Settings.Widgets
import "../../Services/BootEntries.js" as BootEntries
Item {
id: root
property bool onAc: true
readonly property var timeoutValues: [0, 15, 30, 60, 120, 180, 300, 600, 900, 1200, 1800, 3600, 5400, 7200, 10800]
readonly property var timeoutOptions: timeoutValues.map(seconds => seconds === 0 ? I18n.tr("Never", "timeout option meaning the action never happens") : I18n.duration(seconds))
readonly property var gracePeriodValues: [1, 2, 3, 4, 5, 10, 15, 20, 30]
readonly property var gracePeriodOptions: gracePeriodValues.map(seconds => I18n.duration(seconds))
function sourceKey(suffix) {
return (onAc ? "ac" : "battery") + suffix;
}
function timeoutText(key) {
const index = timeoutValues.indexOf(SettingsData[key]);
return timeoutOptions[index >= 0 ? index : 0];
}
function setTimeoutFromText(key, text) {
const index = timeoutOptions.indexOf(text);
if (index < 0)
return;
SettingsData.set(key, timeoutValues[index]);
}
function gracePeriodText(key) {
const index = gracePeriodValues.indexOf(SettingsData[key]);
return gracePeriodOptions[index >= 0 ? index : 4];
}
function setGracePeriodFromText(key, text) {
const index = gracePeriodOptions.indexOf(text);
if (index < 0)
return;
SettingsData.set(key, gracePeriodValues[index]);
}
SettingsPage {
id: mainColumn
SettingsCard {
width: parent.width
iconName: "schedule"
title: I18n.tr("Idle", "noun, settings card title for idle timeout settings")
settingKey: "idleSettings"
SettingsButtonGroupRow {
text: I18n.tr("Power source")
visible: BatteryService.batteryAvailable
model: [I18n.tr("AC power"), I18n.tr("Battery")]
currentIndex: root.onAc ? 0 : 1
checkEnabled: false
onSelectionChanged: (index, selected) => {
if (!selected)
return;
root.onAc = index === 0;
}
}
SettingsDropdownRow {
settingKey: "lockTimeout"
tags: ["lock", "timeout", "idle", "automatic", "security"]
text: I18n.tr("Automatically lock after")
resetKeys: [root.sourceKey("LockTimeout")]
options: root.timeoutOptions
currentValue: root.timeoutText(root.sourceKey("LockTimeout"))
onValueChanged: value => root.setTimeoutFromText(root.sourceKey("LockTimeout"), value)
}
SettingsDropdownRow {
settingKey: "monitorTimeout"
tags: ["monitor", "display", "screen", "timeout", "off", "idle"]
text: I18n.tr("Turn off displays after")
resetKeys: [root.sourceKey("MonitorTimeout")]
options: root.timeoutOptions
currentValue: root.timeoutText(root.sourceKey("MonitorTimeout"))
onValueChanged: value => root.setTimeoutFromText(root.sourceKey("MonitorTimeout"), value)
}
SettingsDropdownRow {
settingKey: "postLockMonitorTimeout"
tags: ["monitor", "display", "screen", "timeout", "off", "lock", "after", "post"]
text: I18n.tr("Turn off displays after lock")
resetKeys: [root.sourceKey("PostLockMonitorTimeout")]
options: root.timeoutOptions
currentValue: root.timeoutText(root.sourceKey("PostLockMonitorTimeout"))
onValueChanged: value => root.setTimeoutFromText(root.sourceKey("PostLockMonitorTimeout"), value)
}
SettingsDropdownRow {
settingKey: "suspendTimeout"
tags: ["suspend", "sleep", "timeout", "idle", "system"]
text: I18n.tr("Suspend system after")
resetKeys: [root.sourceKey("SuspendTimeout")]
options: root.timeoutOptions
currentValue: root.timeoutText(root.sourceKey("SuspendTimeout"))
onValueChanged: value => root.setTimeoutFromText(root.sourceKey("SuspendTimeout"), value)
}
SettingsDropdownRow {
settingKey: "suspendBehavior"
tags: ["suspend", "hibernate", "sleep", "behavior"]
visible: SessionService.hibernateSupported
text: I18n.tr("Suspend behavior")
resetKeys: [root.sourceKey("SuspendBehavior")]
options: [I18n.tr("Suspend"), I18n.tr("Hibernate"), I18n.tr("Suspend then hibernate")]
currentValue: options[SettingsData[root.sourceKey("SuspendBehavior")]] ?? options[0]
onValueChanged: value => {
const index = options.indexOf(value);
if (index < 0)
return;
SettingsData.set(root.sourceKey("SuspendBehavior"), index);
}
}
SettingsToggleRow {
settingKey: "lockBeforeSuspend"
tags: ["lock", "suspend", "sleep", "security"]
text: I18n.tr("Lock before suspend")
checked: SettingsData.lockBeforeSuspend
visible: SessionService.loginctlAvailable && SettingsData.loginctlLockIntegration
onToggled: checked => SettingsData.set("lockBeforeSuspend", checked)
}
}
SettingsCard {
width: parent.width
iconName: "gradient"
title: I18n.tr("Fade")
settingKey: "idleFade"
tags: ["fade", "grace", "period", "lock", "dpms", "display"]
SettingsToggleRow {
settingKey: "fadeToLockEnabled"
tags: ["fade", "lock", "screen", "idle", "grace period"]
text: I18n.tr("Fade to lock screen")
description: I18n.tr("Fades the screen out first. Input during the fade cancels it", "idle fade toggle description")
checked: SettingsData.fadeToLockEnabled
onToggled: checked => SettingsData.set("fadeToLockEnabled", checked)
}
SettingsDropdownRow {
settingKey: "fadeToLockGracePeriod"
tags: ["fade", "grace", "period", "timeout", "lock"]
text: I18n.tr("Lock fade grace period")
visible: SettingsData.fadeToLockEnabled
options: root.gracePeriodOptions
currentValue: root.gracePeriodText("fadeToLockGracePeriod")
onValueChanged: value => root.setGracePeriodFromText("fadeToLockGracePeriod", value)
}
SettingsToggleRow {
settingKey: "fadeToDpmsEnabled"
tags: ["fade", "dpms", "monitor", "screen", "idle", "grace period"]
text: I18n.tr("Fade to display off")
description: I18n.tr("Fades the screen out first. Input during the fade cancels it", "idle fade toggle description")
checked: SettingsData.fadeToDpmsEnabled
onToggled: checked => SettingsData.set("fadeToDpmsEnabled", checked)
}
SettingsDropdownRow {
settingKey: "fadeToDpmsGracePeriod"
tags: ["fade", "grace", "period", "timeout", "dpms", "monitor"]
text: I18n.tr("Display fade grace period")
visible: SettingsData.fadeToDpmsEnabled
options: root.gracePeriodOptions
currentValue: root.gracePeriodText("fadeToDpmsGracePeriod")
onValueChanged: value => root.setGracePeriodFromText("fadeToDpmsGracePeriod", value)
}
}
SettingsCard {
width: parent.width
iconName: "tune"
title: I18n.tr("Power menu")
settingKey: "powerMenu"
SettingsToggleRow {
settingKey: "powerMenuGridLayout"
tags: ["power", "menu", "grid", "layout", "list"]
text: I18n.tr("Grid layout")
checked: SettingsData.powerMenuGridLayout
onToggled: checked => SettingsData.set("powerMenuGridLayout", checked)
}
SettingsDropdownRow {
readonly property var actionValues: ["reboot", "logout", "poweroff", "lock", "suspend", "restart", "hibernate", "softreboot"]
settingKey: "powerMenuDefaultAction"
tags: ["power", "menu", "default", "action", "reboot", "logout", "shutdown"]
text: I18n.tr("Default selected action")
options: [I18n.tr("Reboot"), I18n.tr("Log out"), I18n.tr("Power off"), I18n.tr("Lock"), I18n.tr("Suspend"), I18n.tr("Restart DMS"), I18n.tr("Hibernate"), I18n.tr("Soft reboot")]
currentValue: {
const index = actionValues.indexOf(SettingsData.powerMenuDefaultAction || "logout");
return index >= 0 ? options[index] : I18n.tr("Log out");
}
onValueChanged: value => {
const index = options.indexOf(value);
if (index < 0)
return;
SettingsData.set("powerMenuDefaultAction", actionValues[index]);
}
}
Repeater {
model: [
{
key: "reboot",
label: I18n.tr("Show reboot")
},
{
key: "logout",
label: I18n.tr("Show log out")
},
{
key: "poweroff",
label: I18n.tr("Show power off")
},
{
key: "lock",
label: I18n.tr("Show lock")
},
{
key: "suspend",
label: I18n.tr("Show suspend")
},
{
key: "restart",
label: I18n.tr("Show restart DMS")
},
{
key: "switchuser",
label: I18n.tr("Show switch user")
},
{
key: "hibernate",
label: I18n.tr("Show hibernate"),
hibernate: true
},
{
key: "softreboot",
label: I18n.tr("Show soft reboot"),
softreboot: true
}
]
SettingsToggleRow {
required property var modelData
settingKey: "powerMenuAction_" + modelData.key
tags: ["power", "menu", "action", "show", modelData.key]
text: modelData.label
visible: {
if (modelData.hibernate)
return SessionService.hibernateSupported;
if (modelData.softreboot)
return SessionService.softRebootSupported;
return true;
}
checked: SettingsData.powerMenuActions.includes(modelData.key)
onToggled: checked => {
const others = SettingsData.powerMenuActions.filter(action => action !== modelData.key);
SettingsData.set("powerMenuActions", checked ? others.concat([modelData.key]) : others);
}
}
}
}
SettingsCard {
width: parent.width
iconName: "restart_alt"
title: I18n.tr("Reboot to another OS", "settings card title, lists EFI boot entries the power menu can reboot into")
settingKey: "powerMenuBootEntries"
tags: ["power", "menu", "reboot", "boot", "efi", "uefi", "windows", "dual boot", "bootnext"]
// Saved entries stay listed wherever this config lands, so they can always be removed
visible: SettingsData.powerMenuBootEntries.length > 0 || (BootEntryService.status !== "unknown" && BootEntryService.status !== "noEfi")
Component.onCompleted: BootEntryService.refresh()
SettingsRow {
visible: BootEntryService.unavailableReason !== ""
title: BootEntryService.unavailableReason
titleColor: Theme.surfaceVariantText
}
SettingsRow {
id: bootEntryPicker
readonly property var options: BootEntries.pickerOptions(BootEntryService.entries, BootEntryService.currentId, SettingsData.powerMenuBootEntries)
visible: BootEntryService.status === "ready"
title: I18n.tr("Add entry", "settings row that adds an EFI boot entry to the power menu")
DankDropdown {
id: bootEntryDropdown
enabled: bootEntryPicker.options.length > 0
Accessible.name: bootEntryPicker.title
width: Math.min(dropdownWidth, bootEntryPicker.width - SettingsMetrics.rowPaddingH * 2)
options: bootEntryPicker.options.map(option => option.name)
emptyText: I18n.tr("Select", "verb, dropdown placeholder or option that opens a picker")
onValueChanged: value => {
const option = bootEntryPicker.options.find(option => option.name === value);
currentValue = "";
if (!option)
return;
SettingsData.set("powerMenuBootEntries", SettingsData.powerMenuBootEntries.concat([
{
id: option.id,
label: option.label
}
]));
}
}
}
Repeater {
model: SettingsData.powerMenuBootEntries
delegate: SettingsRow {
id: bootEntryRow
required property var modelData
required property int index
title: modelData.label
subtitle: "Boot" + modelData.id
iconName: "restart_alt"
DankActionButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "delete"
iconColor: Theme.error
Accessible.name: I18n.tr("Remove", "verb, button that removes an item from a list")
onClicked: SettingsData.set("powerMenuBootEntries", SettingsData.powerMenuBootEntries.filter((entry, i) => i !== bootEntryRow.index))
}
}
}
SettingsRow {
visible: BootEntryService.status === "ready" && !SettingsData.powerMenuBootEntries.length
title: I18n.tr("No items added yet", "empty list of saved EFI boot entries")
titleColor: Theme.surfaceVariantText
}
}
SettingsCard {
width: parent.width
iconName: "check_circle"
title: I18n.tr("Confirmation", "power settings card title, hold to confirm power actions")
settingKey: "powerConfirmation"
SettingsToggleRow {
settingKey: "powerActionConfirm"
tags: ["power", "confirm", "hold", "button", "safety"]
text: I18n.tr("Hold to confirm")
checked: SettingsData.powerActionConfirm
onToggled: checked => SettingsData.set("powerActionConfirm", checked)
}
SettingsDropdownRow {
readonly property var durationValues: [0.25, 0.5, 0.75, 1, 2, 3, 5, 10]
enabled: SettingsData.powerActionConfirm
settingKey: "powerActionHoldDuration"
tags: ["power", "hold", "duration", "confirm", "time"]
text: I18n.tr("Hold duration")
options: durationValues.map(seconds => I18n.duration(seconds))
currentValue: {
const index = durationValues.indexOf(SettingsData.powerActionHoldDuration);
return index >= 0 ? options[index] : I18n.duration(0.5);
}
onValueChanged: value => {
const index = options.indexOf(value);
if (index < 0)
return;
SettingsData.set("powerActionHoldDuration", durationValues[index]);
}
}
}
SettingsCard {
width: parent.width
iconName: "developer_mode"
title: I18n.tr("Custom commands")
settingKey: "customPowerActions"
tags: ["lock", "logout", "suspend", "hibernate", "reboot", "poweroff", "power off", "shutdown", "command", "script", "override"]
Repeater {
model: [
{
key: "customPowerActionLock",
icon: "lock",
label: I18n.tr("Lock"),
placeholder: "/usr/bin/myLock.sh"
},
{
key: "customPowerActionLogout",
icon: "logout",
label: I18n.tr("Log out"),
placeholder: "/usr/bin/myLogout.sh"
},
{
key: "customPowerActionSuspend",
icon: "bedtime",
label: I18n.tr("Suspend"),
placeholder: "/usr/bin/mySuspend.sh"
},
{
key: "customPowerActionHibernate",
icon: "ac_unit",
label: I18n.tr("Hibernate"),
placeholder: "/usr/bin/myHibernate.sh"
},
{
key: "customPowerActionReboot",
icon: "restart_alt",
label: I18n.tr("Reboot"),
placeholder: "/usr/bin/myReboot.sh"
},
{
key: "customPowerActionPowerOff",
icon: "power_settings_new",
label: I18n.tr("Power off"),
placeholder: "/usr/bin/myPowerOff.sh"
}
]
SettingsTextFieldRow {
required property var modelData
settingKey: modelData.key
tags: ["power", "command", "override", modelData.label]
text: modelData.label
leftIconName: modelData.icon
value: SettingsData[modelData.key] || ""
placeholderText: modelData.placeholder
onValueEdited: value => SettingsData.set(modelData.key, value.trim())
}
}
}
}
}