From 052e47614ba362f871db9dd1f30f45d46cd30108 Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 10 Jul 2025 19:11:35 -0400 Subject: [PATCH] iSome extra widgets and adjustments --- Services/BatteryService.qml | 16 +- Services/MprisController.qml | 1 - Services/WeatherService.qml | 62 ++++--- Widgets/AppLauncher.qml | 244 ++++++++++++++----------- Widgets/BatteryControlPopup.qml | 16 ++ Widgets/BatteryWidget.qml | 66 +++---- Widgets/PowerButton.qml | 74 ++++++++ Widgets/PowerConfirmDialog.qml | 203 +++++++++++++++++++++ Widgets/PowerMenuPopup.qml | 305 ++++++++++++++++++++++++++++++++ Widgets/SystemTrayWidget.qml | 74 ++------ Widgets/TopBar.qml | 6 +- Widgets/qmldir | 5 +- shell.qml | 7 + 13 files changed, 839 insertions(+), 240 deletions(-) create mode 100644 Widgets/PowerButton.qml create mode 100644 Widgets/PowerConfirmDialog.qml create mode 100644 Widgets/PowerMenuPopup.qml diff --git a/Services/BatteryService.qml b/Services/BatteryService.qml index 1d7b5b92..53b14e22 100644 --- a/Services/BatteryService.qml +++ b/Services/BatteryService.qml @@ -7,18 +7,19 @@ pragma ComponentBehavior: Bound Singleton { id: root + // Battery properties property bool batteryAvailable: false property int batteryLevel: 0 - property string batteryStatus: "Unknown" // "Charging", "Discharging", "Full", "Not charging", "Unknown" - property int timeRemaining: 0 // minutes + property string batteryStatus: "Unknown" + property int timeRemaining: 0 property bool isCharging: false property bool isLowBattery: false - property int batteryHealth: 100 // percentage + property int batteryHealth: 100 property string batteryTechnology: "Unknown" property int cycleCount: 0 - property int batteryCapacity: 0 // mAh + property int batteryCapacity: 0 property var powerProfiles: [] - property string activePowerProfile: "balanced" + property string activePowerProfile: "" // Check if battery is available Process { @@ -238,12 +239,13 @@ Singleton { } } - // Update battery status every 30 seconds + + // Update timer Timer { interval: 30000 running: root.batteryAvailable repeat: true - triggeredOnStart: false + triggeredOnStart: true onTriggered: { batteryStatusChecker.running = true powerProfilesChecker.running = true diff --git a/Services/MprisController.qml b/Services/MprisController.qml index 46f0e8fa..966e0564 100644 --- a/Services/MprisController.qml +++ b/Services/MprisController.qml @@ -145,7 +145,6 @@ Singleton { running: true repeat: true onTriggered: { - console.log(`[MprisController] Players: ${Mpris.players.length}, Active: ${activePlayer?.identity || 'none'}, Playing: ${isPlaying}`) if (activePlayer) { console.log(` Track: ${activePlayer.trackTitle || 'Unknown'} by ${activePlayer.trackArtist || 'Unknown'}`) console.log(` State: ${activePlayer.playbackState}`) diff --git a/Services/WeatherService.qml b/Services/WeatherService.qml index 774f4a53..13b880a8 100644 --- a/Services/WeatherService.qml +++ b/Services/WeatherService.qml @@ -79,37 +79,49 @@ Singleton { Process { id: weatherFetcher - command: ["bash", "-c", "curl -s 'wttr.in/?format=j1' | jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'"] + command: ["bash", "-c", "curl -s 'wttr.in/?format=j1'"] running: false stdout: StdioCollector { onStreamFinished: { - if (text.trim() && text.trim().startsWith("{")) { - try { - let parsedData = JSON.parse(text.trim()) - if (parsedData.current && parsedData.location) { - root.weather = { - available: true, - temp: parseInt(parsedData.current.temp_C || 0), - tempF: parseInt(parsedData.current.temp_F || 0), - city: parsedData.location.areaName[0]?.value || "Unknown", - wCode: parsedData.current.weatherCode || "113", - humidity: parseInt(parsedData.current.humidity || 0), - wind: (parsedData.current.windspeedKmph || 0) + " km/h", - sunrise: parsedData.astronomy?.sunrise || "06:00", - sunset: parsedData.astronomy?.sunset || "18:00", - uv: parseInt(parsedData.current.uvIndex || 0), - pressure: parseInt(parsedData.current.pressure || 0) - } - console.log("Weather updated:", root.weather.city, root.weather.temp + "°C") - } - } catch (e) { - console.warn("Failed to parse weather data:", e.message) - root.weather.available = false - } - } else { + const raw = text.trim() + if (!raw || raw[0] !== "{") { console.warn("No valid weather data received") root.weather.available = false + return + } + + try { + const data = JSON.parse(raw) + + const current = data.current_condition?.[0] || {} + const location = data.nearest_area?.[0] || {} + const astronomy = data.weather?.[0]?.astronomy?.[0] || {} + + if (!Object.keys(current).length || !Object.keys(location).length) { + throw new Error("Required fields missing") + } + + root.weather = { + available: true, + temp: Number(current.temp_C) || 0, + tempF: Number(current.temp_F) || 0, + city: location.areaName?.[0]?.value || "Unknown", + wCode: current.weatherCode || "113", + humidity: Number(current.humidity) || 0, + wind: `${current.windspeedKmph || 0} km/h`, + sunrise: astronomy.sunrise || "06:00", + sunset: astronomy.sunset || "18:00", + uv: Number(current.uvIndex) || 0, + pressure: Number(current.pressure) || 0 + } + + console.log("Weather updated:", root.weather.city, + `${root.weather.temp}°C`) + + } catch (e) { + console.warn("Failed to parse weather data:", e.message) + root.weather.available = false } } } diff --git a/Widgets/AppLauncher.qml b/Widgets/AppLauncher.qml index bbc8b39b..e657e95e 100644 --- a/Widgets/AppLauncher.qml +++ b/Widgets/AppLauncher.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import Qt5Compat.GraphicalEffects import Quickshell import Quickshell.Widgets import Quickshell.Wayland @@ -600,69 +601,6 @@ PanelWindow { } } - // Category dropdown - Rectangle { - width: 200 - height: Math.min(250, categories.length * 40 + activeTheme.spacingM * 2) - radius: activeTheme.cornerRadiusLarge - color: activeTheme.surfaceContainer - border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.2) - border.width: 1 - visible: showCategories - z: 100 - - // Drop shadow - Rectangle { - anchors.fill: parent - anchors.margins: -2 - color: "transparent" - radius: parent.radius + 2 - border.color: Qt.rgba(0, 0, 0, 0.1) - border.width: 1 - z: -1 - } - - ScrollView { - anchors.fill: parent - anchors.margins: activeTheme.spacingS - clip: true - - ListView { - model: categories - spacing: 4 - - delegate: Rectangle { - width: ListView.view.width - height: 36 - radius: activeTheme.cornerRadiusSmall - color: catArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.08) : "transparent" - - Text { - anchors.left: parent.left - anchors.leftMargin: activeTheme.spacingM - anchors.verticalCenter: parent.verticalCenter - text: modelData - font.pixelSize: activeTheme.fontSizeMedium - color: selectedCategory === modelData ? activeTheme.primary : activeTheme.surfaceText - font.weight: selectedCategory === modelData ? Font.Medium : Font.Normal - } - - MouseArea { - id: catArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - selectedCategory = modelData - showCategories = false - updateFilteredModel() - } - } - } - } - } - } - // App grid/list container Rectangle { width: parent.width @@ -715,32 +653,41 @@ PanelWindow { height: 56 anchors.verticalCenter: parent.verticalCenter - IconImage { + Loader { anchors.fill: parent - source: Quickshell.iconPath(model.icon, "application-x-executable") - smooth: true - visible: status === Image.Ready + sourceComponent: model.icon ? iconComponent : fallbackComponent - onStatusChanged: { - if (status === Image.Error && model.name.includes("Avahi")) { - console.log("Avahi icon failed to load:", model.icon, "->", source) + Component { + id: iconComponent + IconImage { + source: model.icon ? Quickshell.iconPath(model.icon) : "" + smooth: true + asynchronous: true + + onStatusChanged: { + if (status === Image.Error || status === Image.Null) { + parent.sourceComponent = fallbackComponent + } + } } } - } - - // Fallback for missing icons - Rectangle { - anchors.fill: parent - visible: !parent.children[0].visible - color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.3) - radius: activeTheme.cornerRadiusLarge - Text { - anchors.centerIn: parent - text: model.name ? model.name.charAt(0).toUpperCase() : "A" - font.pixelSize: activeTheme.iconSizeLarge - color: activeTheme.surfaceVariantText - font.weight: Font.Medium + Component { + id: fallbackComponent + Rectangle { + color: Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.1) + radius: activeTheme.cornerRadiusLarge + border.width: 1 + border.color: Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.2) + + Text { + anchors.centerIn: parent + text: model.name ? model.name.charAt(0).toUpperCase() : "A" + font.pixelSize: 28 + color: activeTheme.primary + font.weight: Font.Bold + } + } } } } @@ -801,8 +748,8 @@ PanelWindow { // Center the grid content property int columnsCount: Math.floor(width / cellWidth) property int remainingSpace: width - (columnsCount * cellWidth) - anchors.leftMargin: Math.max(activeTheme.spacingS, remainingSpace / 2) - anchors.rightMargin: anchors.leftMargin + leftMargin: Math.max(activeTheme.spacingS, remainingSpace / 2) + rightMargin: leftMargin model: filteredModel @@ -832,32 +779,41 @@ PanelWindow { height: iconSize anchors.horizontalCenter: parent.horizontalCenter - IconImage { + Loader { anchors.fill: parent - source: Quickshell.iconPath(model.icon, "application-x-executable") - smooth: true - visible: status === Image.Ready + sourceComponent: model.icon ? gridIconComponent : gridFallbackComponent - onStatusChanged: { - if (status === Image.Error && model.name.includes("Avahi")) { - console.log("Avahi grid icon failed to load:", model.icon, "->", source) + Component { + id: gridIconComponent + IconImage { + source: model.icon ? Quickshell.iconPath(model.icon) : "" + smooth: true + asynchronous: true + + onStatusChanged: { + if (status === Image.Error || status === Image.Null) { + parent.sourceComponent = gridFallbackComponent + } + } } } - } - - // Fallback for missing icons - Rectangle { - anchors.fill: parent - visible: !parent.children[0].visible - color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.3) - radius: activeTheme.cornerRadiusLarge - Text { - anchors.centerIn: parent - text: model.name ? model.name.charAt(0).toUpperCase() : "A" - font.pixelSize: activeTheme.iconSizeLarge - color: activeTheme.surfaceVariantText - font.weight: Font.Medium + Component { + id: gridFallbackComponent + Rectangle { + color: Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.1) + radius: activeTheme.cornerRadiusLarge + border.width: 1 + border.color: Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.2) + + Text { + anchors.centerIn: parent + text: model.name ? model.name.charAt(0).toUpperCase() : "A" + font.pixelSize: parent.parent.parent.iconSize / 2 + color: activeTheme.primary + font.weight: Font.Bold + } + } } } } @@ -892,6 +848,80 @@ PanelWindow { } } } + + // Category dropdown overlay - now positioned absolutely + Rectangle { + id: categoryDropdown + width: 200 + height: Math.min(250, categories.length * 40 + activeTheme.spacingM * 2) + radius: activeTheme.cornerRadiusLarge + color: activeTheme.surfaceContainer + border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.2) + border.width: 1 + visible: showCategories + z: 1000 + + // Position it below the category button + anchors.top: parent.top + anchors.topMargin: 140 + (searchField.text.length === 0 ? 0 : -40) + anchors.left: parent.left + + // Drop shadow + Rectangle { + anchors.fill: parent + anchors.margins: -4 + color: "transparent" + radius: parent.radius + 4 + z: -1 + + layer.enabled: true + layer.effect: DropShadow { + radius: 8 + samples: 16 + color: Qt.rgba(0, 0, 0, 0.2) + } + } + + ScrollView { + anchors.fill: parent + anchors.margins: activeTheme.spacingS + clip: true + + ListView { + model: categories + spacing: 4 + + delegate: Rectangle { + width: ListView.view.width + height: 36 + radius: activeTheme.cornerRadiusSmall + color: catArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.08) : "transparent" + + Text { + anchors.left: parent.left + anchors.leftMargin: activeTheme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: modelData + font.pixelSize: activeTheme.fontSizeMedium + color: selectedCategory === modelData ? activeTheme.primary : activeTheme.surfaceText + font.weight: selectedCategory === modelData ? Font.Medium : Font.Normal + } + + MouseArea { + id: catArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + selectedCategory = modelData + showCategories = false + updateFilteredModel() + } + } + } + } + } + } } } } diff --git a/Widgets/BatteryControlPopup.qml b/Widgets/BatteryControlPopup.qml index 94bdc0dc..40655e1b 100644 --- a/Widgets/BatteryControlPopup.qml +++ b/Widgets/BatteryControlPopup.qml @@ -26,6 +26,14 @@ PanelWindow { right: true bottom: true } + + // Click outside to dismiss overlay + MouseArea { + anchors.fill: parent + onClicked: { + root.batteryPopupVisible = false + } + } Rectangle { width: Math.min(380, parent.width - Theme.spacingL * 2) @@ -40,6 +48,14 @@ PanelWindow { opacity: root.batteryPopupVisible ? 1.0 : 0.0 scale: root.batteryPopupVisible ? 1.0 : 0.85 + // Prevent click-through to background + MouseArea { + anchors.fill: parent + onClicked: { + // Consume the click to prevent it from reaching the background + } + } + Behavior on opacity { NumberAnimation { duration: Theme.mediumDuration diff --git a/Widgets/BatteryWidget.qml b/Widgets/BatteryWidget.qml index 538897b2..96a9a27a 100644 --- a/Widgets/BatteryWidget.qml +++ b/Widgets/BatteryWidget.qml @@ -8,53 +8,33 @@ Rectangle { property bool batteryPopupVisible: false - width: Theme.barHeight - Theme.spacingS - height: Theme.barHeight - Theme.spacingS - radius: Theme.cornerRadiusSmall - color: batteryArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent" + width: 48 + height: 32 + radius: Theme.cornerRadius + color: batteryArea.containsMouse || batteryPopupVisible ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : + Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08) visible: BatteryService.batteryAvailable - Row { - anchors.centerIn: parent - spacing: Theme.spacingXS - - // Battery icon - Text { - text: BatteryService.getBatteryIcon() - font.family: Theme.iconFont - font.pixelSize: Theme.iconSize - color: { - if (!BatteryService.batteryAvailable) return Theme.surfaceText - if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error - if (BatteryService.isCharging) return Theme.primary - return Theme.surfaceText - } - anchors.verticalCenter: parent.verticalCenter - - // Subtle animation for charging - RotationAnimation on rotation { - running: BatteryService.isCharging - loops: Animation.Infinite - from: 0 - to: 360 - duration: 8000 - easing.type: Easing.Linear - } + // Battery icon - Material Design icons already show level visually + Text { + text: BatteryService.getBatteryIcon() + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: { + if (!BatteryService.batteryAvailable) return Theme.surfaceText + if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error + if (BatteryService.isCharging) return Theme.primary + return Theme.surfaceText } + anchors.centerIn: parent - // Battery percentage - Text { - text: BatteryService.batteryLevel + "%" - font.pixelSize: Theme.fontSizeSmall - color: { - if (!BatteryService.batteryAvailable) return Theme.surfaceText - if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error - if (BatteryService.isCharging) return Theme.primary - return Theme.surfaceText - } - font.weight: Font.Medium - anchors.verticalCenter: parent.verticalCenter - visible: BatteryService.batteryAvailable + // Subtle pulse animation for charging + SequentialAnimation on opacity { + running: BatteryService.isCharging + loops: Animation.Infinite + NumberAnimation { to: 0.6; duration: 1000; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 1000; easing.type: Easing.InOutQuad } } } diff --git a/Widgets/PowerButton.qml b/Widgets/PowerButton.qml new file mode 100644 index 00000000..a021387f --- /dev/null +++ b/Widgets/PowerButton.qml @@ -0,0 +1,74 @@ +import QtQuick +import QtQuick.Controls +import "../Common" + +Rectangle { + id: powerButton + + width: 48 + height: 32 + radius: Theme.cornerRadius + color: powerArea.containsMouse || root.powerMenuVisible ? + Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.16) : + Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.08) + + // Power icon + Text { + text: "power_settings_new" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: powerArea.containsMouse || root.powerMenuVisible ? Theme.error : Theme.surfaceText + anchors.centerIn: parent + } + + MouseArea { + id: powerArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + root.powerMenuVisible = !root.powerMenuVisible + } + } + + // Tooltip on hover + Rectangle { + id: powerTooltip + width: tooltipText.contentWidth + Theme.spacingM * 2 + height: tooltipText.contentHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainer + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + border.width: 1 + visible: powerArea.containsMouse && !root.powerMenuVisible + + anchors.bottom: parent.top + anchors.bottomMargin: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + + opacity: powerArea.containsMouse ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Text { + id: tooltipText + text: "Power Menu" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.centerIn: parent + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } +} \ No newline at end of file diff --git a/Widgets/PowerConfirmDialog.qml b/Widgets/PowerConfirmDialog.qml new file mode 100644 index 00000000..bf42ee51 --- /dev/null +++ b/Widgets/PowerConfirmDialog.qml @@ -0,0 +1,203 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Io +import "../Common" + +PanelWindow { + id: powerConfirmDialog + + visible: root.powerConfirmVisible + + implicitWidth: 400 + implicitHeight: 300 + + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand + + color: "transparent" + + anchors { + top: true + left: true + right: true + bottom: true + } + + // Darkened background + Rectangle { + anchors.fill: parent + color: "black" + opacity: 0.5 + } + + Rectangle { + width: Math.min(400, parent.width - Theme.spacingL * 2) + height: Math.min(200, parent.height - Theme.spacingL * 2) + anchors.centerIn: parent + color: Theme.surfaceContainer + radius: Theme.cornerRadiusLarge + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: 1 + + opacity: root.powerConfirmVisible ? 1.0 : 0.0 + scale: root.powerConfirmVisible ? 1.0 : 0.9 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + Behavior on scale { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + // Title + Text { + text: root.powerConfirmTitle + font.pixelSize: Theme.fontSizeLarge + color: { + switch(root.powerConfirmAction) { + case "poweroff": return Theme.error + case "reboot": return Theme.warning + default: return Theme.surfaceText + } + } + font.weight: Font.Medium + width: parent.width + horizontalAlignment: Text.AlignHCenter + } + + // Message + Text { + text: root.powerConfirmMessage + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + + Item { height: Theme.spacingL } + + // Buttons + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingM + + // Cancel button + Rectangle { + width: 120 + height: 40 + radius: Theme.cornerRadius + color: cancelButton.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.3) + + Text { + text: "Cancel" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + anchors.centerIn: parent + } + + MouseArea { + id: cancelButton + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.powerConfirmVisible = false + } + } + } + + // Confirm button + Rectangle { + width: 120 + height: 40 + radius: Theme.cornerRadius + color: { + let baseColor + switch(root.powerConfirmAction) { + case "poweroff": baseColor = Theme.error; break + case "reboot": baseColor = Theme.warning; break + default: baseColor = Theme.primary; break + } + return confirmButton.containsMouse ? + Qt.rgba(baseColor.r, baseColor.g, baseColor.b, 0.9) : + baseColor + } + + Text { + text: "Confirm" + font.pixelSize: Theme.fontSizeMedium + color: Theme.onPrimary + font.weight: Font.Medium + anchors.centerIn: parent + } + + MouseArea { + id: confirmButton + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.powerConfirmVisible = false + executePowerAction(root.powerConfirmAction) + } + } + } + } + } + } + + function executePowerAction(action) { + console.log("Executing power action:", action) + + let command = [] + switch(action) { + case "logout": + // Try multiple logout commands for different environments + command = ["bash", "-c", "loginctl terminate-user $USER || pkill -KILL -u $USER || gnome-session-quit --force || xfce4-session-logout --logout || i3-msg exit || swaymsg exit || niri msg quit"] + break + case "suspend": + command = ["systemctl", "suspend"] + break + case "reboot": + command = ["systemctl", "reboot"] + break + case "poweroff": + command = ["systemctl", "poweroff"] + break + } + + if (command.length > 0) { + powerActionProcess.command = command + powerActionProcess.running = true + } + } + + Process { + id: powerActionProcess + running: false + + onExited: (exitCode) => { + if (exitCode !== 0) { + console.error("Power action failed with exit code:", exitCode) + } + } + } +} \ No newline at end of file diff --git a/Widgets/PowerMenuPopup.qml b/Widgets/PowerMenuPopup.qml new file mode 100644 index 00000000..3f77462e --- /dev/null +++ b/Widgets/PowerMenuPopup.qml @@ -0,0 +1,305 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import Quickshell.Io +import "../Common" + +PanelWindow { + id: powerMenuPopup + + visible: root.powerMenuVisible + + implicitWidth: 400 + implicitHeight: 320 + + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + color: "transparent" + + anchors { + top: true + left: true + right: true + bottom: true + } + + // Click outside to dismiss overlay + MouseArea { + anchors.fill: parent + onClicked: { + root.powerMenuVisible = false + } + } + + Rectangle { + width: Math.min(320, parent.width - Theme.spacingL * 2) + height: 320 // Fixed height to prevent cropping + x: Math.max(Theme.spacingL, parent.width - width - Theme.spacingL) + y: Theme.barHeight + Theme.spacingS + color: Theme.surfaceContainer + radius: Theme.cornerRadiusLarge + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: 1 + + opacity: root.powerMenuVisible ? 1.0 : 0.0 + scale: root.powerMenuVisible ? 1.0 : 0.85 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + Behavior on scale { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + // Prevent click-through to background + MouseArea { + anchors.fill: parent + onClicked: { + // Consume the click to prevent it from reaching the background + } + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + // Header + Row { + width: parent.width + + Text { + text: "Power Options" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: parent.width - 150; height: 1 } + + Rectangle { + width: 32 + height: 32 + radius: 16 + color: closePowerArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize - 4 + color: closePowerArea.containsMouse ? Theme.error : Theme.surfaceText + } + + MouseArea { + id: closePowerArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.powerMenuVisible = false + } + } + } + } + + // Power options + Column { + width: parent.width + spacing: Theme.spacingS + + // Log Out + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: logoutArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + Text { + text: "logout" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "Log Out" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: logoutArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.powerMenuVisible = false + root.powerConfirmAction = "logout" + root.powerConfirmTitle = "Log Out" + root.powerConfirmMessage = "Are you sure you want to log out?" + root.powerConfirmVisible = true + } + } + } + + // Suspend + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: suspendArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + Text { + text: "bedtime" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "Suspend" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: suspendArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.powerMenuVisible = false + root.powerConfirmAction = "suspend" + root.powerConfirmTitle = "Suspend" + root.powerConfirmMessage = "Are you sure you want to suspend the system?" + root.powerConfirmVisible = true + } + } + } + + // Reboot + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: rebootArea.containsMouse ? Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + Text { + text: "restart_alt" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "Reboot" + font.pixelSize: Theme.fontSizeMedium + color: rebootArea.containsMouse ? Theme.warning : Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: rebootArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.powerMenuVisible = false + root.powerConfirmAction = "reboot" + root.powerConfirmTitle = "Reboot" + root.powerConfirmMessage = "Are you sure you want to reboot the system?" + root.powerConfirmVisible = true + } + } + } + + // Power Off + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: powerOffArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + Text { + text: "power_settings_new" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "Power Off" + font.pixelSize: Theme.fontSizeMedium + color: powerOffArea.containsMouse ? Theme.error : Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: powerOffArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.powerMenuVisible = false + root.powerConfirmAction = "poweroff" + root.powerConfirmTitle = "Power Off" + root.powerConfirmMessage = "Are you sure you want to power off the system?" + root.powerConfirmVisible = true + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Widgets/SystemTrayWidget.qml b/Widgets/SystemTrayWidget.qml index d0b0a468..7939f721 100644 --- a/Widgets/SystemTrayWidget.qml +++ b/Widgets/SystemTrayWidget.qml @@ -2,51 +2,37 @@ import QtQuick import QtQuick.Controls import Quickshell import Quickshell.Services.SystemTray +import Quickshell.Widgets -Rectangle { +Item { property var theme - property var root - width: Math.max(40, systemTrayRow.implicitWidth + theme.spacingS * 2) height: 32 - radius: theme.cornerRadius - color: Qt.rgba(theme.secondary.r, theme.secondary.g, theme.secondary.b, 0.08) - anchors.verticalCenter: parent.verticalCenter - visible: systemTrayRow.children.length > 0 + implicitWidth: trayRow.implicitWidth + visible: trayRow.children.length > 0 Row { - id: systemTrayRow + id: trayRow anchors.centerIn: parent spacing: theme.spacingXS Repeater { model: SystemTray.items + delegate: Rectangle { + required property SystemTrayItem modelData + width: 24 height: 24 radius: theme.cornerRadiusSmall color: trayItemArea.containsMouse ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12) : "transparent" - property var trayItem: modelData - - Image { + IconImage { anchors.centerIn: parent width: 18 height: 18 - source: { - let icon = trayItem?.icon || ""; - if (!icon) return ""; - - if (icon.includes("?path=")) { - const [name, path] = icon.split("?path="); - const fileName = name.substring(name.lastIndexOf("/") + 1); - return `file://${path}/${fileName}`; - } - return icon; - } - asynchronous: true + source: parent.modelData.icon smooth: true - fillMode: Image.PreserveAspectFit } MouseArea { @@ -57,45 +43,23 @@ Rectangle { cursorShape: Qt.PointingHandCursor onClicked: (mouse) => { - if (!trayItem) return; - if (mouse.button === Qt.LeftButton) { - if (!trayItem.onlyMenu) { - trayItem.activate() - } + parent.modelData.activate() } else if (mouse.button === Qt.RightButton) { - if (trayItem.hasMenu) { - console.log("Right-click detected, showing menu for:", trayItem.title || "Unknown") - customTrayMenu.showMenu(mouse.x, mouse.y) - } else { - console.log("No menu available for:", trayItem.title || "Unknown") - } + menuHandler.showMenu() } } } + // Simple menu handling for now QtObject { - id: customTrayMenu + id: menuHandler - property bool menuVisible: false - - function showMenu(x, y) { - root.currentTrayMenu = customTrayMenu - root.currentTrayItem = trayItem - - root.trayMenuX = parent.parent.parent.parent.x + parent.parent.parent.parent.width - 180 - theme.spacingL - root.trayMenuY = theme.barHeight + theme.spacingS - - console.log("Showing menu at:", root.trayMenuX, root.trayMenuY) - menuVisible = true - root.showTrayMenu = true - } - - function hideMenu() { - menuVisible = false - root.showTrayMenu = false - root.currentTrayMenu = null - root.currentTrayItem = null + function showMenu() { + if (parent.modelData.hasMenu) { + console.log("Right-click menu for:", parent.modelData.title || "Unknown") + // TODO: Implement proper menu positioning + } } } diff --git a/Widgets/TopBar.qml b/Widgets/TopBar.qml index 0c73f4f2..8d1147cb 100644 --- a/Widgets/TopBar.qml +++ b/Widgets/TopBar.qml @@ -804,7 +804,11 @@ PanelWindow { } } } + + // Power Button + PowerButton { + anchors.verticalCenter: parent.verticalCenter + } } } } - diff --git a/Widgets/qmldir b/Widgets/qmldir index d41a3623..b2316b8d 100644 --- a/Widgets/qmldir +++ b/Widgets/qmldir @@ -18,4 +18,7 @@ ClipboardHistory 1.0 ClipboardHistory.qml CustomSlider 1.0 CustomSlider.qml InputDialog 1.0 InputDialog.qml BatteryWidget 1.0 BatteryWidget.qml -BatteryControlPopup 1.0 BatteryControlPopup.qml \ No newline at end of file +BatteryControlPopup 1.0 BatteryControlPopup.qml +PowerButton 1.0 PowerButton.qml +PowerMenuPopup 1.0 PowerMenuPopup.qml +PowerConfirmDialog 1.0 PowerConfirmDialog.qml \ No newline at end of file diff --git a/shell.qml b/shell.qml index b9b7c6f6..f11a6ad9 100644 --- a/shell.qml +++ b/shell.qml @@ -34,6 +34,11 @@ ShellRoot { property bool hasActiveMedia: activePlayer && (activePlayer.trackTitle || activePlayer.trackArtist) property bool controlCenterVisible: false property bool batteryPopupVisible: false + property bool powerMenuVisible: false + property bool powerConfirmVisible: false + property string powerConfirmAction: "" + property string powerConfirmTitle: "" + property string powerConfirmMessage: "" // Network properties from NetworkService property string networkStatus: NetworkService.networkStatus @@ -259,6 +264,8 @@ ShellRoot { id: globalInputDialog } BatteryControlPopup {} + PowerMenuPopup {} + PowerConfirmDialog {} // Application and clipboard components AppLauncher {