Files
noctalia-shell/Modules/Panels/SessionMenu/SessionMenu.qml
T
2026-02-09 14:44:34 +01:00

1250 lines
37 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Widgets
import qs.Commons
import qs.Modules.MainScreen
import qs.Services.Compositor
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
readonly property bool largeButtonsStyle: Settings.data.sessionMenu.largeButtonsStyle || false
readonly property bool largeButtonsLayout: Settings.data.sessionMenu.largeButtonsLayout || "grid"
// Make panel background transparent for large buttons style
panelBackgroundColor: largeButtonsStyle ? "transparent" : Color.mSurface
preferredWidth: largeButtonsStyle ? 0 : Math.round(440 * Style.uiScaleRatio)
preferredWidthRatio: largeButtonsStyle ? 1.0 : 0
preferredHeight: {
if (largeButtonsStyle) {
return 0; // Use ratio instead
}
var headerHeight = Settings.data.sessionMenu.showHeader ? Style.baseWidgetSize * 0.6 : 0;
var dividerHeight = Settings.data.sessionMenu.showHeader ? Style.marginS : 0;
var buttonHeight = Style.baseWidgetSize * 1.3 * Style.uiScaleRatio;
var buttonSpacing = Style.marginS;
var enabledCount = powerOptions.length;
var headerSpacing = Settings.data.sessionMenu.showHeader ? (Style.marginL * 2) : 0;
var baseHeight = (Style.marginL * 4) + headerHeight + dividerHeight + headerSpacing;
var buttonsHeight = enabledCount > 0 ? (buttonHeight * enabledCount) + (buttonSpacing * (enabledCount - 1)) : 0;
return Math.round(baseHeight + buttonsHeight);
}
preferredHeightRatio: largeButtonsStyle ? 1.0 : 0
// Positioning - large buttons style is always centered and fullscreen
readonly property string panelPosition: Settings.data.sessionMenu.position
panelAnchorHorizontalCenter: largeButtonsStyle || panelPosition === "center" || panelPosition.endsWith("_center")
panelAnchorVerticalCenter: largeButtonsStyle || panelPosition === "center"
panelAnchorLeft: !largeButtonsStyle && panelPosition !== "center" && panelPosition.endsWith("_left")
panelAnchorRight: !largeButtonsStyle && panelPosition !== "center" && panelPosition.endsWith("_right")
panelAnchorBottom: !largeButtonsStyle && panelPosition.startsWith("bottom_")
panelAnchorTop: !largeButtonsStyle && panelPosition.startsWith("top_")
// SessionMenu handle it's own closing logic
property bool closeWithEscape: false
// Timer properties
readonly property int timerDuration: Settings.data.sessionMenu.countdownDuration
property string pendingAction: ""
property bool timerActive: false
property int timeRemaining: 0
// Navigation properties
property int selectedIndex: -1
property bool ignoreMouseHover: true // Transient flag, should always be true on init
// Global mouse tracking for movement detection across delegates
property real globalLastMouseX: 0
property real globalLastMouseY: 0
property bool globalMouseInitialized: false
property bool mouseTrackingReady: false // Delay tracking until panel is settled
Timer {
id: mouseTrackingDelayTimer
interval: Style.animationNormal + 50 // Wait for panel animation to complete + safety margin
repeat: false
onTriggered: {
root.mouseTrackingReady = true;
root.globalMouseInitialized = false; // Reset so we get fresh initial position
}
}
// Action metadata mapping
readonly property var actionMetadata: {
"lock": {
"icon": "lock",
"title": I18n.tr("common.lock"),
"isShutdown": false
},
"suspend": {
"icon": "suspend",
"title": I18n.tr("common.suspend"),
"isShutdown": false
},
"hibernate": {
"icon": "hibernate",
"title": I18n.tr("common.hibernate"),
"isShutdown": false
},
"reboot": {
"icon": "reboot",
"title": I18n.tr("common.reboot"),
"isShutdown": false
},
"logout": {
"icon": "logout",
"title": I18n.tr("common.logout"),
"isShutdown": false
},
"shutdown": {
"icon": "shutdown",
"title": I18n.tr("common.shutdown"),
"isShutdown": true
}
}
// Build powerOptions from settings, filtering enabled ones and adding metadata
// _powerOptionsVersion forces re-evaluation when settings change
property int _powerOptionsVersion: 0
property var powerOptions: {
// Reference version to trigger re-evaluation
void (_powerOptionsVersion);
var options = [];
var settingsOptions = Settings.data.sessionMenu.powerOptions || [];
for (var i = 0; i < settingsOptions.length; i++) {
var settingOption = settingsOptions[i];
if (settingOption.enabled && actionMetadata[settingOption.action]) {
var metadata = actionMetadata[settingOption.action];
options.push({
"action": settingOption.action,
"icon": metadata.icon,
"title": metadata.title,
"isShutdown": metadata.isShutdown,
"countdownEnabled": settingOption.countdownEnabled !== undefined ? settingOption.countdownEnabled : true,
"command": settingOption.command || "",
"keybind": settingOption.keybind || ""
});
}
}
return options;
}
Connections {
target: Settings.data.sessionMenu
function onPowerOptionsChanged() {
root._powerOptionsVersion++;
}
}
// Lifecycle handlers
onOpened: {
selectedIndex = -1;
ignoreMouseHover = true;
globalMouseInitialized = false;
mouseTrackingReady = false;
mouseTrackingDelayTimer.restart();
}
onClosed: {
cancelTimer();
selectedIndex = -1;
ignoreMouseHover = true;
}
// Timer management
function startTimer(action) {
// Check if global countdown is disabled
if (!Settings.data.sessionMenu.enableCountdown) {
executeAction(action);
return;
}
// Check per-item countdown setting
var option = null;
for (var i = 0; i < powerOptions.length; i++) {
if (powerOptions[i].action === action) {
option = powerOptions[i];
break;
}
}
// If this specific action has countdown disabled, execute immediately
if (option && option.countdownEnabled === false) {
executeAction(action);
return;
}
if (timerActive && pendingAction === action) {
// Second click - execute immediately
executeAction(action);
return;
}
pendingAction = action;
timeRemaining = timerDuration;
timerActive = true;
countdownTimer.start();
}
function cancelTimer() {
timerActive = false;
pendingAction = "";
timeRemaining = 0;
countdownTimer.stop();
}
function executeAction(action) {
// Stop timer but don't reset other properties yet
countdownTimer.stop();
// Use default behavior or custom command handled by CompositorService
switch (action) {
case "lock":
CompositorService.lock();
break;
case "suspend":
// Check if we should lock before suspending
if (Settings.data.general.lockOnSuspend) {
CompositorService.lockAndSuspend();
} else {
CompositorService.suspend();
}
break;
case "hibernate":
CompositorService.hibernate();
break;
case "reboot":
CompositorService.reboot();
break;
case "logout":
CompositorService.logout();
break;
case "shutdown":
CompositorService.shutdown();
break;
}
// Reset timer state and close panel
cancelTimer();
root.close();
}
// Navigation functions
function selectNextWrapped() {
if (powerOptions.length > 0) {
if (selectedIndex < 0) {
selectedIndex = 0;
} else {
selectedIndex = (selectedIndex + 1) % powerOptions.length;
}
}
}
function selectPreviousWrapped() {
if (powerOptions.length > 0) {
if (selectedIndex < 0) {
selectedIndex = powerOptions.length - 1;
} else {
selectedIndex = (((selectedIndex - 1) % powerOptions.length) + powerOptions.length) % powerOptions.length;
}
}
}
function selectFirst() {
if (powerOptions.length > 0) {
selectedIndex = 0;
} else {
selectedIndex = -1;
}
}
function selectLast() {
if (powerOptions.length > 0) {
selectedIndex = powerOptions.length - 1;
} else {
selectedIndex = -1;
}
}
function getGridInfo() {
let columns, rows;
if (Settings.data.sessionMenu.largeButtonsLayout === "single-row") {
columns = powerOptions.length;
rows = 1;
} else {
columns = Math.min(3, Math.ceil(Math.sqrt(powerOptions.length)));
rows = Math.ceil(powerOptions.length / columns);
}
return {
columns,
rows,
currentRow: selectedIndex >= 0 ? Math.floor(selectedIndex / columns) : -1,
currentCol: selectedIndex >= 0 ? selectedIndex % columns : -1,
itemsInRow: row => Math.min(columns, powerOptions.length - row * columns)
};
}
// Unified navigation function
function navigateGrid(direction) {
if (powerOptions.length === 0)
return;
const grid = getGridInfo();
// If no selection, start at first item
let newRow = grid.currentRow >= 0 ? grid.currentRow : 0;
let newCol = grid.currentCol >= 0 ? grid.currentCol : 0;
switch (direction) {
case "left":
newCol = newCol - 1 < 0 ? grid.itemsInRow(newRow) - 1 : newCol - 1;
break;
case "right":
newCol = newCol + 1 >= grid.itemsInRow(newRow) ? 0 : newCol + 1;
break;
case "up":
newRow = newRow - 1 < 0 ? grid.rows - 1 : newRow - 1;
break;
case "down":
newRow = newRow + 1 >= grid.rows ? 0 : newRow + 1;
break;
}
// For vertical movement, clamp column if row has fewer items
if (direction === "up" || direction === "down") {
const itemsInNewRow = grid.itemsInRow(newRow);
newCol = Math.min(newCol, itemsInNewRow - 1);
}
const newIndex = newRow * grid.columns + newCol;
if (newIndex < powerOptions.length) {
selectedIndex = newIndex;
}
}
function activate() {
if (powerOptions.length > 0 && selectedIndex >= 0 && powerOptions[selectedIndex]) {
const option = powerOptions[selectedIndex];
startTimer(option.action);
}
}
function getKeybindString(event) {
let keyStr = "";
if (event.modifiers & Qt.ControlModifier)
keyStr += "Ctrl+";
if (event.modifiers & Qt.AltModifier)
keyStr += "Alt+";
if (event.modifiers & Qt.ShiftModifier)
keyStr += "Shift+";
let keyName = "";
let rawText = event.text;
if (event.key >= Qt.Key_A && event.key <= Qt.Key_Z || event.key >= Qt.Key_0 && event.key <= Qt.Key_9) {
keyName = String.fromCharCode(event.key);
} else if (event.key >= Qt.Key_F1 && event.key <= Qt.Key_F12) {
keyName = "F" + (event.key - Qt.Key_F1 + 1);
} else if (rawText && rawText.length > 0 && rawText.charCodeAt(0) > 31) {
keyName = rawText.toUpperCase();
if (event.modifiers & Qt.ShiftModifier) {
const shiftMap = {
"!": "1",
"\"": "2",
"§": "3",
"$": "4",
"%": "5",
"&": "6",
"/": "7",
"(": "8",
")": "9",
"=": "0",
"@": "2",
"#": "3",
"^": "6",
"*": "8"
};
if (shiftMap[keyName]) {
keyName = shiftMap[keyName];
}
}
} else {
switch (event.key) {
case Qt.Key_Escape:
keyName = "Esc";
break;
case Qt.Key_Space:
keyName = "Space";
break;
case Qt.Key_Return:
keyName = "Return";
break;
case Qt.Key_Enter:
keyName = "Enter";
break;
case Qt.Key_Tab:
keyName = "Tab";
break;
case Qt.Key_Backspace:
keyName = "Backspace";
break;
case Qt.Key_Delete:
keyName = "Del";
break;
case Qt.Key_Insert:
keyName = "Ins";
break;
case Qt.Key_Home:
keyName = "Home";
break;
case Qt.Key_End:
keyName = "End";
break;
case Qt.Key_PageUp:
keyName = "PgUp";
break;
case Qt.Key_PageDown:
keyName = "PgDn";
break;
case Qt.Key_Left:
keyName = "Left";
break;
case Qt.Key_Right:
keyName = "Right";
break;
case Qt.Key_Up:
keyName = "Up";
break;
case Qt.Key_Down:
keyName = "Down";
break;
}
}
if (!keyName)
return "";
return keyStr + keyName;
}
function checkKey(event, settingName) {
// Map simplified names to the actual setting property names
var propName = "key" + settingName.charAt(0).toUpperCase() + settingName.slice(1);
var boundKey = Settings.data.general.keybinds[propName];
if (!boundKey)
return false;
var eventString = getKeybindString(event);
return eventString === boundKey;
}
function handleUp() {
if (largeButtonsStyle) {
navigateGrid("up");
} else {
selectPreviousWrapped();
}
}
function handleDown() {
if (largeButtonsStyle) {
navigateGrid("down");
} else {
selectNextWrapped();
}
}
function handleLeft() {
if (largeButtonsStyle) {
navigateGrid("left");
} else {
selectPreviousWrapped();
}
}
function handleRight() {
if (largeButtonsStyle) {
navigateGrid("right");
} else {
selectNextWrapped();
}
}
function handleEnter() {
activate();
}
function handleEscape() {
if (timerActive) {
cancelTimer();
} else {
root.close();
}
}
// Override keyboard handlers from SmartPanel
function onEscapePressed() {
handleEscape();
}
function onTabPressed() {
selectNextWrapped();
}
function onBackTabPressed() {
selectPreviousWrapped();
}
function onLeftPressed() {
handleLeft();
}
function onRightPressed() {
handleRight();
}
function onUpPressed() {
handleUp();
}
function onDownPressed() {
handleDown();
}
function onEnterPressed() {
handleEnter();
}
function onHomePressed() {
selectFirst();
}
function onEndPressed() {
selectLast();
}
function checkKeybind(event) {
if (powerOptions.length === 0)
return false;
// Construct key string in the same format as the recorder
// Ignore modifier keys by themselves
if (event.key === Qt.Key_Control || event.key === Qt.Key_Shift || event.key === Qt.Key_Alt || event.key === Qt.Key_Meta) {
return false;
}
const pressedKeybind = getKeybindString(event);
if (!pressedKeybind)
return false;
for (var i = 0; i < powerOptions.length; i++) {
const option = powerOptions[i];
if (option.keybind === pressedKeybind) {
selectedIndex = i;
startTimer(option.action);
return true;
}
}
return false;
}
// Number selection handler (kept for backward compatibility if needed, though keybinds might override common keys)
function onNumberPressed(number) {
if (!Settings.data.sessionMenu.showNumberLabels) {
return;
}
// Number is 1-based, convert to 0-based index
const index = number - 1;
if (index >= 0 && index < powerOptions.length) {
const option = powerOptions[index];
selectedIndex = index;
startTimer(option.action);
}
}
// Countdown timer
Timer {
id: countdownTimer
interval: 100
repeat: true
onTriggered: {
timeRemaining -= interval;
if (timeRemaining <= 0) {
executeAction(pendingAction);
}
}
}
panelContent: Rectangle {
id: panelContent
color: "transparent"
focus: true
// For large buttons style, use full screen dimensions
readonly property var contentPreferredWidth: largeButtonsStyle ? (root.screen?.width || root.width || 0) : undefined
readonly property var contentPreferredHeight: largeButtonsStyle ? (root.screen?.height || root.height || 0) : undefined
// Focus management
Connections {
target: root
function onOpened() {
Qt.callLater(() => {
panelContent.forceActiveFocus();
});
}
}
Keys.onPressed: event => {
// Check custom entry keybinds first
if (root.checkKeybind(event)) {
event.accepted = true;
return;
}
// Check global navigation keybinds
if (checkKey(event, 'up')) {
handleUp();
event.accepted = true;
return;
}
if (checkKey(event, 'down')) {
handleDown();
event.accepted = true;
return;
}
if (checkKey(event, 'left')) {
handleLeft();
event.accepted = true;
return;
}
if (checkKey(event, 'right')) {
handleRight();
event.accepted = true;
return;
}
if (checkKey(event, 'enter')) {
handleEnter();
event.accepted = true;
return;
}
if (checkKey(event, 'escape')) {
handleEscape();
event.accepted = true;
return;
}
// Block default keys if they weren't matched above
// This prevents 'Up' from working if rebinned to something else
if (event.key === Qt.Key_Up || event.key === Qt.Key_Down || event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Escape) {
event.accepted = true;
return;
}
}
HoverHandler {
id: globalHoverHandler
onPointChanged: {
if (!root.mouseTrackingReady) {
return;
}
if (!root.globalMouseInitialized) {
root.globalLastMouseX = point.position.x;
root.globalLastMouseY = point.position.y;
root.globalMouseInitialized = true;
return;
}
const deltaX = Math.abs(point.position.x - root.globalLastMouseX);
const deltaY = Math.abs(point.position.y - root.globalLastMouseY);
if (deltaX + deltaY >= 5) {
root.ignoreMouseHover = false;
root.globalLastMouseX = point.position.x;
root.globalLastMouseY = point.position.y;
}
}
}
// Timer text for large buttons style (above buttons) - positioned absolutely with background
Rectangle {
id: timerTextContainer
visible: largeButtonsStyle && timerActive
anchors.bottom: largeButtonsContainer.top
anchors.horizontalCenter: largeButtonsContainer.horizontalCenter
anchors.bottomMargin: Style.marginM
width: timerText.width + Style.marginXL * 2
height: timerText.height + Style.marginL * 2
radius: Style.radiusM
color: Qt.alpha(Color.mSurface, Settings.data.ui.panelBackgroundOpacity)
border.color: Color.mOutline
border.width: Style.borderS
z: 1000
NText {
id: timerText
anchors.centerIn: parent
text: I18n.tr("session-menu.action-in-seconds", {
"action": I18n.tr("common." + pendingAction),
"seconds": Math.ceil(timeRemaining / 1000)
})
font.weight: Style.fontWeightBold
pointSize: Style.fontSizeL
color: Color.mOnSurface
}
}
// Large buttons style layout container
ColumnLayout {
id: largeButtonsContainer
visible: largeButtonsStyle
anchors.centerIn: parent
// Large buttons style layout (grid)
GridLayout {
id: largeButtonsGrid
Layout.alignment: Qt.AlignHCenter
columns: Settings.data.sessionMenu.largeButtonsLayout === "single-row" ? powerOptions.length : Math.min(3, Math.ceil(Math.sqrt(powerOptions.length)))
rowSpacing: Style.marginXL
columnSpacing: Style.marginXL
width: columns * 200 * Style.uiScaleRatio + (columns - 1) * Style.marginXL
height: Math.ceil(powerOptions.length / columns) * 200 * Style.uiScaleRatio + (Math.ceil(powerOptions.length / columns) - 1) * Style.marginXL
Repeater {
model: powerOptions
delegate: LargeButton {
Layout.preferredWidth: Math.round(200 * Style.uiScaleRatio)
Layout.preferredHeight: Math.round(200 * Style.uiScaleRatio)
icon: modelData.icon
title: modelData.title
isShutdown: modelData.isShutdown || false
isSelected: index === selectedIndex
number: index + 1
buttonIndex: index
onClicked: {
selectedIndex = index;
startTimer(modelData.action);
}
pending: timerActive && pendingAction === modelData.action
keybind: modelData.keybind || ""
}
}
}
}
// Normal style layout
NBox {
visible: !largeButtonsStyle
anchors.fill: parent
anchors.margins: Style.marginL
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginL
// Header with title and close button
RowLayout {
visible: Settings.data.sessionMenu.showHeader
Layout.fillWidth: true
Layout.preferredHeight: Style.baseWidgetSize * 0.6
NText {
text: timerActive ? I18n.tr("session-menu.action-in-seconds", {
"action": I18n.tr("common." + pendingAction),
"seconds": Math.ceil(timeRemaining / 1000)
}) : I18n.tr("session-menu.title")
font.weight: Style.fontWeightBold
pointSize: Style.fontSizeL
color: timerActive ? Color.mPrimary : Color.mOnSurface
Layout.alignment: Qt.AlignVCenter
verticalAlignment: Text.AlignVCenter
}
Item {
Layout.fillWidth: true
}
NIconButton {
icon: timerActive ? "stop" : "close"
tooltipText: timerActive ? I18n.tr("session-menu.cancel-timer") : I18n.tr("common.close")
Layout.alignment: Qt.AlignVCenter
baseSize: Style.baseWidgetSize * 0.7
colorBg: timerActive ? Qt.alpha(Color.mError, 0.08) : "transparent"
colorFg: timerActive ? Color.mError : Color.mOnSurface
onClicked: {
if (timerActive) {
cancelTimer();
} else {
cancelTimer();
root.close();
}
}
}
}
NDivider {
visible: Settings.data.sessionMenu.showHeader
Layout.fillWidth: true
}
// Power options
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS
Repeater {
model: powerOptions
delegate: PowerButton {
Layout.fillWidth: true
icon: modelData.icon
title: modelData.title
isShutdown: modelData.isShutdown || false
isSelected: index === selectedIndex
number: index + 1
buttonIndex: index
onClicked: {
selectedIndex = index;
startTimer(modelData.action);
}
pending: timerActive && pendingAction === modelData.action
keybind: modelData.keybind || ""
}
}
}
}
}
// Background MouseArea for large buttons style - closes panel when clicking outside buttons
MouseArea {
visible: largeButtonsStyle
anchors.fill: parent
z: -1
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: mouse => {
// Only close if not clicking on a button
// The buttons are above this MouseArea, so clicks on them won't reach here
if (timerActive) {
// Cancel countdown if active
cancelTimer();
} else {
root.close();
}
}
}
}
// Custom power button component
component PowerButton: Rectangle {
id: buttonRoot
// Keybind indicator and countdown text at far right
Item {
id: indicatorGroup
width: (countdownText.visible ? countdownText.width + Style.marginXS : 0) + numberIndicatorRect.width
height: numberIndicatorRect.height
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: Style.marginM
z: 20
// Countdown as plain text (left of keybind)
NText {
id: countdownText
visible: !Settings.data.sessionMenu.showHeader && buttonRoot.pending && timerActive && pendingAction === modelData.action
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: Math.ceil(timeRemaining / 1000)
pointSize: Style.fontSizeS
color: Color.mPrimary
font.weight: Style.fontWeightBold
}
// Keybind/Number indicator (keybind)
Rectangle {
id: numberIndicatorRect
anchors.left: countdownText.visible ? countdownText.right : parent.left
anchors.leftMargin: countdownText.visible ? Style.marginXS : 0
anchors.verticalCenter: parent.verticalCenter
width: Math.max(Style.marginXL, labelText.implicitWidth + Style.marginM)
height: Style.marginXL
radius: Math.min(Style.radiusM, height / 2)
color: (buttonRoot.isSelected || buttonRoot.effectiveHover) ? Color.mOnPrimary : Qt.alpha(Color.mSurfaceVariant, 0.5)
border.width: Style.borderS
border.color: (buttonRoot.isSelected || buttonRoot.effectiveHover) ? Color.mOnPrimary : Color.mOutline
visible: (Settings.data.sessionMenu.showNumberLabels && buttonRoot.number > 0) || buttonRoot.keybind !== ""
NText {
id: labelText
anchors.centerIn: parent
text: buttonRoot.keybind !== "" ? buttonRoot.keybind : buttonRoot.number
pointSize: Style.fontSizeS
font.weight: Style.fontWeightBold
color: (buttonRoot.isSelected || buttonRoot.effectiveHover) ? Color.mPrimary : Color.mOnSurface
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
}
}
}
property string icon: ""
property string title: ""
property bool pending: false
property bool isShutdown: false
property bool isSelected: false
property int number: 0
property string keybind: ""
property int buttonIndex: -1
// Effective hover state that respects ignoreMouseHover
readonly property bool effectiveHover: !root.ignoreMouseHover && mouseArea.containsMouse
signal clicked
height: Style.baseWidgetSize * 1.3 * Style.uiScaleRatio
radius: Style.radiusS
color: {
if (pending) {
return Qt.alpha(Color.mPrimary, 0.08);
}
if (isSelected || effectiveHover) {
return Color.mHover;
}
return "transparent";
}
border.width: pending ? Math.max(Style.borderM) : 0
border.color: pending ? Color.mPrimary : Color.mOutline
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
Item {
anchors.fill: parent
anchors.margins: Style.marginM
// Icon on the left
NIcon {
id: iconElement
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
icon: buttonRoot.icon
color: {
if (buttonRoot.pending)
return Color.mPrimary;
if (buttonRoot.isShutdown && !buttonRoot.isSelected && !buttonRoot.effectiveHover)
return Color.mError;
if (buttonRoot.isSelected || buttonRoot.effectiveHover)
return Color.mOnHover;
return Color.mOnSurface;
}
pointSize: Style.fontSizeXXL
width: Style.baseWidgetSize * 0.5
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
}
// Text content in the middle
ColumnLayout {
anchors.left: iconElement.right
anchors.right: numberIndicator.visible ? numberIndicator.left : parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.marginL
anchors.rightMargin: numberIndicator.visible ? Style.marginM : 0
spacing: 0
NText {
text: buttonRoot.title
font.weight: Style.fontWeightMedium
pointSize: Style.fontSizeM
color: {
if (buttonRoot.pending)
return Color.mPrimary;
if (buttonRoot.isShutdown && !buttonRoot.isSelected && !buttonRoot.effectiveHover)
return Color.mError;
if (buttonRoot.isSelected || buttonRoot.effectiveHover)
return Color.mOnHover;
return Color.mOnSurface;
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
}
}
// Number indicator on the right (when not pending)
Rectangle {
id: numberIndicator
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: Style.marginXL
height: width
radius: Math.min(Style.radiusM, height / 2)
color: Qt.alpha(Color.mSurfaceVariant, 0.5)
border.width: Style.borderS
border.color: Color.mOutline
visible: Settings.data.sessionMenu.showNumberLabels && buttonRoot.number > 0 && !buttonRoot.pending
NText {
id: numberText
anchors.centerIn: parent
text: buttonRoot.number
pointSize: Style.fontSizeS
color: {
if (buttonRoot.isSelected || buttonRoot.effectiveHover)
return Color.mOnHover;
return Color.mOnSurface;
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
if (!root.ignoreMouseHover) {
selectedIndex = buttonRoot.buttonIndex;
}
}
onClicked: buttonRoot.clicked()
}
}
// Large buttons style button component
component LargeButton: Rectangle {
id: largeButtonRoot
property string icon: ""
property string title: ""
property bool pending: false
property bool isShutdown: false
property bool isSelected: false
property int number: 0
property string keybind: ""
property int buttonIndex: -1
// Effective hover state that respects ignoreMouseHover
readonly property bool effectiveHover: !root.ignoreMouseHover && mouseArea.containsMouse
readonly property real hoveredScale: 1.05
signal clicked
property real hoverScale: (isSelected || effectiveHover) ? hoveredScale : 1.0
radius: Style.radiusL
color: {
if (pending) {
return Qt.alpha(Color.mPrimary, 1.0);
}
if (isSelected || effectiveHover) {
return Qt.alpha(Color.mPrimary, 1.0);
}
return Qt.alpha(Color.mSurfaceVariant, Settings.data.ui.panelBackgroundOpacity);
}
border.width: Style.borderS
border.color: Color.mOutline
// Always enable layer to fix nvidia bug, render at 2x size to avoid blur when scaling up
layer.enabled: true
layer.smooth: true
layer.textureSize: Qt.size(Math.ceil(width * 2), Math.ceil(height * 2))
// Scale transform for hover effect
transform: Scale {
origin.x: largeButtonRoot.width / 2
origin.y: largeButtonRoot.height / 2
xScale: hoverScale
yScale: hoverScale
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
Behavior on border.width {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
Behavior on hoverScale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
easing.overshoot: 0.5
}
}
ColumnLayout {
anchors.centerIn: parent
anchors.margins: Style.marginL
spacing: Style.marginM
// Large icon with scale animation
NIcon {
id: iconElement
Layout.alignment: Qt.AlignHCenter
icon: largeButtonRoot.icon
color: {
if (largeButtonRoot.pending)
return Color.mOnPrimary;
if (largeButtonRoot.isShutdown && !largeButtonRoot.isSelected && !largeButtonRoot.effectiveHover)
return Color.mError;
if (largeButtonRoot.isSelected || largeButtonRoot.effectiveHover)
return Color.mOnPrimary;
return Color.mOnSurface;
}
pointSize: Style.fontSizeXXXL * 2.25
width: 90 * Style.uiScaleRatio
height: 90 * Style.uiScaleRatio
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
readonly property real hoveredIconScale: 1.15
property real iconScale: (largeButtonRoot.isSelected || largeButtonRoot.effectiveHover) ? hoveredIconScale : 1.0
// Always enable layer to fix nvidia bug, render at 2x size to avoid blur when scaling up
layer.enabled: true
layer.smooth: true
layer.textureSize: Qt.size(Math.ceil(width * 2), Math.ceil(height * 2))
transform: Scale {
origin.x: iconElement.width / 2
origin.y: iconElement.height / 2
xScale: iconElement.iconScale
yScale: iconElement.iconScale
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
Behavior on iconScale {
NumberAnimation {
duration: Style.animationNormal
easing.type: Easing.OutBack
easing.overshoot: 0.6
}
}
}
// Title text
NText {
Layout.alignment: Qt.AlignHCenter
text: largeButtonRoot.title
font.weight: Style.fontWeightMedium
pointSize: Style.fontSizeL
color: {
if (largeButtonRoot.pending)
return Color.mOnPrimary;
if (largeButtonRoot.isShutdown && !largeButtonRoot.isSelected && !largeButtonRoot.effectiveHover)
return Color.mError;
if (largeButtonRoot.isSelected || largeButtonRoot.effectiveHover)
return Color.mOnPrimary;
return Color.mOnSurface;
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
}
}
// Keybind/Number indicator in top-right corner
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: Style.marginM
width: Math.max(Style.fontSizeM * 2, largeNumberText.implicitWidth + Style.marginM)
height: Style.fontSizeM * 2
radius: Math.min(Style.radiusM, height / 2)
color: (largeButtonRoot.isSelected || largeButtonRoot.effectiveHover) ? Color.mOnPrimary : Qt.alpha(Color.mSurfaceVariant, 0.7)
border.width: Style.borderS
border.color: (largeButtonRoot.isSelected || largeButtonRoot.effectiveHover) ? Color.mOnPrimary : Color.mOutline
visible: (Settings.data.sessionMenu.showNumberLabels && largeButtonRoot.number > 0 || largeButtonRoot.keybind !== "") && !largeButtonRoot.pending
z: 10
NText {
id: largeNumberText
anchors.centerIn: parent
text: largeButtonRoot.keybind !== "" ? largeButtonRoot.keybind : largeButtonRoot.number
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: {
if (largeButtonRoot.isSelected || largeButtonRoot.effectiveHover)
return Color.mPrimary;
return Color.mOnSurface;
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
easing.type: Easing.OutCirc
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: {
if (!root.ignoreMouseHover) {
selectedIndex = largeButtonRoot.buttonIndex;
}
}
onClicked: largeButtonRoot.clicked()
}
}
}