Files
DankMaterialShell/quickshell/Modules/PowerMenu/PowerMenuContent.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

428 lines
14 KiB
QML

import QtQuick
import qs.Common
import qs.Services
import qs.DankCommon.Session
FocusScope {
id: root
property int selectedIndex: 0
onSelectedIndexChanged: {
if (selectedIndex < visibleActions.length)
return;
selectedIndex = Math.max(0, visibleActions.length - 1);
selectedRow = Math.floor(selectedIndex / gridColumns);
selectedCol = selectedIndex % gridColumns;
}
property int selectedRow: 0
property int selectedCol: 0
property var visibleActions: []
property int gridColumns: 3
property int gridRows: 2
readonly property int currentRowColumns: Math.max(1, Math.min(gridColumns, visibleActions.length - selectedRow * gridColumns))
property string holdAction: ""
property int holdActionIndex: -1
property real holdProgress: 0
property bool showHoldHint: false
property bool holdFromKeyboard: false
property string committedAction: ""
readonly property bool needsConfirmation: SettingsData.powerActionConfirm
readonly property int holdDurationMs: SettingsData.powerActionHoldDuration * 1000
readonly property real desiredWidth: powerView.desiredWidth
signal powerActionRequested(string action)
signal lockRequested
signal switchUserRequested
signal closeRequested
implicitHeight: powerView.implicitHeight
function resetState() {
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
showHoldHint = false;
holdFromKeyboard = false;
committedAction = "";
commitFallbackTimer.stop();
updateVisibleActions();
const defaultIndex = getDefaultActionIndex();
selectedIndex = defaultIndex;
if (SettingsData.powerMenuGridLayout) {
selectedRow = Math.floor(defaultIndex / gridColumns);
selectedCol = defaultIndex % gridColumns;
}
}
function actionNeedsConfirm(action) {
return action !== "lock" && action !== "restart";
}
function actionWakesOnKeyRelease(action) {
return action === "suspend" || action === "hibernate";
}
function startHold(action, actionIndex) {
if (committedAction !== "")
return;
if (!needsConfirmation || !actionNeedsConfirm(action)) {
if (holdFromKeyboard && actionWakesOnKeyRelease(action)) {
commitAction(action, actionIndex);
return;
}
executeAction(action);
return;
}
holdAction = action;
holdActionIndex = actionIndex;
holdProgress = 0;
showHoldHint = false;
holdTimer.start();
}
function cancelHold() {
if (holdAction === "")
return;
const wasHolding = holdProgress > 0;
holdTimer.stop();
if (wasHolding && holdProgress < 1) {
showHoldHint = true;
hintTimer.restart();
}
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
}
function completeHold() {
if (holdProgress < 1) {
cancelHold();
return;
}
holdTimer.stop();
if (holdFromKeyboard && actionWakesOnKeyRelease(holdAction)) {
commitAction(holdAction, holdActionIndex);
return;
}
const action = holdAction;
holdAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
}
function commitAction(action, actionIndex) {
holdTimer.stop();
holdAction = "";
holdActionIndex = actionIndex;
committedAction = action;
commitFallbackTimer.restart();
}
function executeCommittedAction() {
if (committedAction === "")
return;
commitFallbackTimer.stop();
const action = committedAction;
committedAction = "";
holdActionIndex = -1;
holdProgress = 0;
executeAction(action);
}
function executeAction(action) {
closeRequested();
if (action === "lock") {
lockRequested();
return;
}
if (action === "switchuser") {
switchUserRequested();
return;
}
root.powerActionRequested(action);
}
Timer {
id: holdTimer
interval: 16
repeat: true
onTriggered: {
root.holdProgress = Math.min(1, root.holdProgress + (interval / root.holdDurationMs));
if (root.holdProgress >= 1) {
stop();
root.completeHold();
}
}
}
Timer {
id: hintTimer
interval: 2000
onTriggered: root.showHoldHint = false
}
Timer {
id: commitFallbackTimer
interval: 5000
onTriggered: root.executeCommittedAction()
}
function updateVisibleActions() {
const allActions = SettingsData.powerMenuActions || ["reboot", "logout", "poweroff", "lock", "suspend", "restart"];
visibleActions = allActions.filter(action => SessionService.isPowerActionSupported(action)).concat(SessionService.extraPowerActions);
if (!SettingsData.powerMenuGridLayout)
return;
const count = visibleActions.length;
if (count === 0) {
gridColumns = 1;
gridRows = 1;
return;
}
if (count <= 3) {
gridColumns = 1;
gridRows = count;
return;
}
if (count === 4) {
gridColumns = 2;
gridRows = 2;
return;
}
gridColumns = 3;
gridRows = Math.ceil(count / 3);
}
function getDefaultActionIndex() {
const defaultAction = SettingsData.powerMenuDefaultAction || "logout";
const index = visibleActions.indexOf(defaultAction);
return index >= 0 ? index : 0;
}
function getActionAtIndex(index) {
if (index < 0 || index >= visibleActions.length)
return "";
return visibleActions[index];
}
function getActionData(action) {
return SessionService.getPowerActionData(action);
}
function selectOption(action, actionIndex) {
startHold(action, actionIndex !== undefined ? actionIndex : -1);
}
Keys.onPressed: event => {
if (event.isAutoRepeat) {
event.accepted = true;
return;
}
if (committedAction !== "") {
event.accepted = true;
return;
}
if (event.key === Qt.Key_Escape) {
cancelHold();
closeRequested();
event.accepted = true;
return;
}
holdFromKeyboard = true;
if (SettingsData.powerMenuGridLayout) {
handleGridNavigation(event);
} else {
handleListNavigation(event);
}
}
Keys.onReleased: event => {
if (event.isAutoRepeat) {
event.accepted = true;
return;
}
if (committedAction !== "") {
event.accepted = true;
executeCommittedAction();
return;
}
if (event.key === Qt.Key_Escape) {
event.accepted = true;
return;
}
handleKeyRelease(event);
}
function isEnterKey(event) {
return event.key === Qt.Key_Return || event.key === Qt.Key_Enter;
}
// The shortcut of each visible action is the key its hint shows. Ctrl combinations navigate.
// fromCharCode truncates to 16 bits, so special keys (0x01xxxxxx) must be rejected first.
function actionForKey(event) {
if (event.modifiers & Qt.ControlModifier || event.key > Qt.Key_Z)
return "";
const key = String.fromCharCode(event.key);
if (!/^[A-Z0-9]$/.test(key))
return "";
return visibleActions.find(action => getActionData(action).key === key) ?? "";
}
function handleKeyRelease(event) {
if (isEnterKey(event) || actionForKey(event) !== "") {
cancelHold();
event.accepted = true;
}
}
function handleActionShortcut(event) {
if (isEnterKey(event)) {
startHold(getActionAtIndex(selectedIndex), selectedIndex);
event.accepted = true;
return true;
}
const action = actionForKey(event);
if (action === "")
return false;
startHold(action, visibleActions.indexOf(action));
event.accepted = true;
return true;
}
function handleListNavigation(event) {
if (handleActionShortcut(event))
return;
switch (event.key) {
case Qt.Key_Up:
case Qt.Key_Backtab:
selectedIndex = (selectedIndex - 1 + visibleActions.length) % visibleActions.length;
event.accepted = true;
break;
case Qt.Key_Down:
case Qt.Key_Tab:
selectedIndex = (selectedIndex + 1) % visibleActions.length;
event.accepted = true;
break;
case Qt.Key_N:
case Qt.Key_J:
if (event.modifiers & Qt.ControlModifier) {
selectedIndex = (selectedIndex + 1) % visibleActions.length;
event.accepted = true;
}
break;
case Qt.Key_P:
case Qt.Key_K:
if (event.modifiers & Qt.ControlModifier) {
selectedIndex = (selectedIndex - 1 + visibleActions.length) % visibleActions.length;
event.accepted = true;
}
break;
}
}
function handleGridNavigation(event) {
if (handleActionShortcut(event))
return;
switch (event.key) {
case Qt.Key_Left:
selectedCol = (selectedCol + (I18n.isRtl ? 1 : -1) + currentRowColumns) % currentRowColumns;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
break;
case Qt.Key_Right:
selectedCol = (selectedCol + (I18n.isRtl ? -1 : 1) + currentRowColumns) % currentRowColumns;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
break;
case Qt.Key_Up:
case Qt.Key_Backtab:
selectedRow = (selectedRow - 1 + gridRows) % gridRows;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
break;
case Qt.Key_Down:
case Qt.Key_Tab:
selectedRow = (selectedRow + 1) % gridRows;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
break;
case Qt.Key_N:
if (event.modifiers & Qt.ControlModifier) {
selectedCol = (selectedCol + 1) % currentRowColumns;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
}
break;
case Qt.Key_P:
if (event.modifiers & Qt.ControlModifier) {
selectedCol = (selectedCol - 1 + currentRowColumns) % currentRowColumns;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
}
break;
case Qt.Key_J:
if (event.modifiers & Qt.ControlModifier) {
selectedRow = (selectedRow + 1) % gridRows;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
}
break;
case Qt.Key_K:
if (event.modifiers & Qt.ControlModifier) {
selectedRow = (selectedRow - 1 + gridRows) % gridRows;
selectedIndex = selectedRow * gridColumns + selectedCol;
event.accepted = true;
}
break;
}
}
Component.onCompleted: resetState()
PowerMenuView {
id: powerView
anchors.fill: parent
color: "transparent"
actions: root.visibleActions
actionProvider: root.getActionData
gridLayout: SettingsData.powerMenuGridLayout
gridColumns: root.gridColumns
selectedIndex: root.selectedIndex
holdActionIndex: root.holdActionIndex
holdProgress: root.holdProgress
showHint: root.needsConfirmation
hintWarning: root.showHoldHint
hintIcon: root.showHoldHint ? "warning" : root.actionNeedsConfirm(root.getActionAtIndex(root.selectedIndex)) ? "touch_app" : "bolt"
hintText: {
if (root.committedAction !== "")
return I18n.tr("Release to confirm");
if (root.showHoldHint)
return I18n.tr("Hold longer to confirm");
if (!root.actionNeedsConfirm(root.getActionAtIndex(root.selectedIndex)))
return I18n.tr("Activates immediately");
const totalMs = root.holdDurationMs;
const remainingMs = Math.ceil(totalMs * (1 - root.holdProgress));
if (root.holdProgress > 0)
return totalMs < 1000 ? I18n.tr("Hold to confirm (%1 ms)", "power menu hint, %1 is a number of milliseconds").arg(remainingMs) : I18n.tr("Hold to confirm (%1s)", "power menu hint, %1 is a number of seconds").arg(Math.ceil(remainingMs / 1000));
return totalMs < 1000 ? I18n.tr("Hold to confirm (%1 ms)").arg(totalMs) : I18n.tr("Hold to confirm (%1s)").arg(totalMs / 1000);
}
onActionPressed: index => {
root.holdFromKeyboard = false;
root.selectedIndex = index;
root.selectedRow = Math.floor(index / root.gridColumns);
root.selectedCol = index % root.gridColumns;
root.startHold(root.visibleActions[index], index);
}
onActionReleased: root.cancelHold()
onActionCanceled: root.cancelHold()
}
}