diff --git a/somnus/BarWidget.qml b/somnus/BarWidget.qml new file mode 100644 index 000000000..5f19cf87c --- /dev/null +++ b/somnus/BarWidget.qml @@ -0,0 +1,64 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +NIconButton { + id: root + + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property string iconColorKey: pluginApi?.pluginSettings?.iconColor + || pluginApi?.manifest?.metadata?.defaultSettings?.iconColor || "primary" + + icon: "power" + tooltipText: pluginApi?.tr("widget.tooltip") + tooltipDirection: BarService.getTooltipDirection(screen?.name) + baseSize: Style.getCapsuleHeightForScreen(screen?.name) + applyUiScale: false + customRadius: Style.radiusL + + colorBg: Style.capsuleColor + colorFg: Color.resolveColorKey(iconColorKey) + colorBgHover: Color.mHover + colorFgHover: Color.mOnHover + colorBorder: "transparent" + colorBorderHover: "transparent" + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + onClicked: { + Logger.i("Somnus", "Bar button pressed") + pluginApi?.mainInstance?.togglePanel?.(screen) + } + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": pluginApi?.tr("menu.settings"), + "action": "settings", + "icon": "settings" + } + ] + + onTriggered: action => { + contextMenu.close() + PanelService.closeContextMenu(screen) + if (action === "settings") { + BarService.openPluginSettings(screen, pluginApi?.manifest) + } + } + } + + onRightClicked: { + PanelService.showContextMenu(contextMenu, root, screen) + } +} diff --git a/somnus/Main.qml b/somnus/Main.qml new file mode 100644 index 000000000..50e0ad342 --- /dev/null +++ b/somnus/Main.qml @@ -0,0 +1,58 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +Item { + id: root + + property var pluginApi: null + property bool isOpen: false + + IpcHandler { + target: "plugin:somnus" + function toggle() { + var screen = null + try { var screens = Quickshell.screens; if (screens && screens.length > 0) screen = screens[0] } + catch (e) {} + if (!screen && pluginApi?.panelOpenScreen) screen = pluginApi.panelOpenScreen + Logger.i("Somnus", "IPC toggle: screen=" + (screen?.name ?? "null") + ", type=" + typeof screen + ", screens.len=" + (Quickshell.screens?.length ?? -1)) + root.togglePanel(screen) + } + } + + function togglePanel(screen) { + if (isOpen) closePanel() + else openPanel(screen) + } + + function openPanel(screen) { + if (windowLoader.active) return + Logger.i("Somnus", "openPanel called, screen=" + (screen?.name ?? "null") + ", pluginApi=" + (root.pluginApi !== null)) + windowLoader.setSource("SomnusWindow.qml", { + "screenRef": screen, + "pluginApi": root.pluginApi + }) + windowLoader.active = true + isOpen = true + } + + function closePanel() { + windowLoader.active = false + isOpen = false + } + + Loader { + id: windowLoader + active: false + asynchronous: false + + onLoaded: { + Logger.i("Somnus", "Loader onLoaded fired, item=" + (item !== null) + ", item.width=" + (item?.width ?? -1)) + item.closeRequested.connect(root.closePanel) + } + onStatusChanged: { + Logger.i("Somnus", "Loader status=" + status + " (Loaded=" + Loader.Loaded + ")") + } + } +} \ No newline at end of file diff --git a/somnus/README.md b/somnus/README.md new file mode 100644 index 000000000..37cc3ea9b --- /dev/null +++ b/somnus/README.md @@ -0,0 +1,75 @@ +# Somnus Power Menu + +A fullscreen power menu, inspired by wlogout. Provides quick access to all system power actions with a clean, animated interface. + +![preview](preview.png) + +## Features + +- **Two display modes**: Overlay (fullscreen with wallpaper blur and configurable tint) and Panel (centered window with semitransparent background) +- **Six power actions**: Lock, Logout, Suspend, Hibernate, Reboot, Shutdown +- **Smart suspend**: Optionally lock the session before suspending +- **Staggered animations**: Smooth entry animation per button with configurable toggle +- **Hover effects**: Scale and color change with adjustable desaturation +- **Full keyboard navigation**: Arrow keys, number keys (1–6), Enter, Escape +- **Bar widget**: Left-click toggles the menu, right-click opens settings +- **CLI support**: Toggle via `qs ipc call plugin:somnus toggle` +- **Theme-aware**: The bar button and hover colors automatically adapt to your Noctalia theme — whether it's generated from your wallpaper or a manual theme, light or dark mode. + +## Configuration + +All settings are available through the Noctalia settings panel. Right-click the bar button and select "Settings", or open it from the shell settings menu. + +Buttons are arranged in a 3×2 grid. Number keys map to each position: + +``` +1 → Lock 2 → Logout 3 → Suspend +4 → Hibernate 5 → Reboot 6 → Shutdown +``` + +| Setting | Default | Description | +|---|---|---| +| `Icon Color` | `primary` | Color of the bar button icon | +| `Lock on Suspend` | `enabled` | Lock session before suspending | +| `Button Width` | `470 px` | Width of each power button | +| `Button Height` | `280 px` | Height of each power button | +| `Button Spacing` | `20 px` | Gap between buttons in the grid | +| `Button Opacity` | `50%` | Background opacity of idle buttons | +| `Hover Color` | `primary` | Background color on hover/focus | +| `Hover Desaturation` | `0%` | Desaturate the hover color (0–85%) | +| `Display Mode` | `Overlay` | Overlay (fullscreen + blur) or Panel (centered) | +| `Overlay Blur` | `75%` | Background blur intensity in overlay mode | +| `Overlay Tint` | `60%` | Dark tint opacity in overlay mode | +| `Panel Tint` | `70%` | Background opacity in panel mode | +| `Animations` | `enabled` | Toggle all entry and hover animations | + +## Keyboard Shortcuts + +| Key | Action | +|---|---| +| `Arrow keys` | Move focus between buttons | +| `1–6` | Select and execute action by position | +| `Enter / Space` | Execute focused action | +| `Escape / toggle keybind` | Close the menu | + +## Keybind + +Add to your compositor config: + +### Hyprland (Lua) +```lua +hl.bind("SUPER + X", hl.dsp.exec_cmd("qs -c noctalia-shell ipc call plugin:somnus toggle")) +``` + +### Hyprland (Config) +```conf +bind = SUPER, X, exec, qs -c noctalia-shell ipc call plugin:somnus toggle +``` + +## License + +MIT + +## Credits + +- **wlogout** — Interface design inspiration (button grid layout and power actions). diff --git a/somnus/Settings.qml b/somnus/Settings.qml new file mode 100644 index 000000000..494041f35 --- /dev/null +++ b/somnus/Settings.qml @@ -0,0 +1,283 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + + property var cfg: pluginApi?.pluginSettings || ({}) + property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({}) + + property string editIconColor: cfg.iconColor + ?? defaults.iconColor ?? "primary" + + property string editHoverColor: cfg.hoverColor + ?? defaults.hoverColor ?? "primary" + + property real editBtnWidth: cfg.buttonWidth + ?? defaults.buttonWidth ?? 470 + + property real editBtnHeight: cfg.buttonHeight + ?? defaults.buttonHeight ?? 280 + + property real editBtnSpacing: cfg.buttonSpacing + ?? defaults.buttonSpacing ?? 20 + + property real editBtnOpacity: cfg.buttonOpacity + ?? defaults.buttonOpacity ?? 0.50 + + property bool editUseOverlay: cfg.useOverlay + ?? defaults.useOverlay ?? true + + property bool editAnimationsEnabled: cfg.animationsEnabled + ?? defaults.animationsEnabled ?? true + + property bool editLockOnSuspend: cfg.lockOnSuspend + ?? defaults.lockOnSuspend ?? true + + property real editHoverDesaturation: cfg.hoverDesaturation + ?? defaults.hoverDesaturation ?? 0.0 + + property real editOverlayBlur: cfg.overlayBlur + ?? defaults.overlayBlur ?? 0.75 + + property real editOverlayTint: cfg.overlayTint + ?? defaults.overlayTint ?? 0.6 + + property real editPanelTint: cfg.panelTint + ?? defaults.panelTint ?? 0.7 + + spacing: Style.marginM + + NText { + text: pluginApi?.tr("header.title") + pointSize: Style.fontSizeXL + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NText { + text: pluginApi?.tr("header.desc") + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + Layout.bottomMargin: Style.marginM + } + + NDivider { Layout.fillWidth: true } + + NText { + text: pluginApi?.tr("section.barButton") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NColorChoice { + label: pluginApi?.tr("settings.iconColor.label") + description: pluginApi?.tr("settings.iconColor.desc") + currentKey: root.editIconColor + onSelected: key => { root.editIconColor = key; } + defaultValue: defaults.iconColor || "primary" + } + + NDivider { Layout.fillWidth: true; Layout.topMargin: Style.marginS; Layout.bottomMargin: Style.marginS } + + NText { + text: pluginApi?.tr("section.behavior") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.lockOnSuspend.label") + description: pluginApi?.tr("settings.lockOnSuspend.desc") + checked: root.editLockOnSuspend + onToggled: checked => { root.editLockOnSuspend = checked; } + defaultValue: defaults.lockOnSuspend ?? true + } + + NDivider { Layout.fillWidth: true; Layout.topMargin: Style.marginS; Layout.bottomMargin: Style.marginS } + + NText { + text: pluginApi?.tr("section.buttons") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.buttonWidth.label") + from: 200; to: 500; stepSize: 10; snapAlways: true + value: root.editBtnWidth + text: root.editBtnWidth + "px" + defaultValue: defaults.buttonWidth ?? 470 + showReset: true + onMoved: v => { root.editBtnWidth = v; } + } + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.buttonHeight.label") + from: 150; to: 400; stepSize: 10; snapAlways: true + value: root.editBtnHeight + text: root.editBtnHeight + "px" + defaultValue: defaults.buttonHeight ?? 280 + showReset: true + onMoved: v => { root.editBtnHeight = v; } + } + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.buttonSpacing.label") + description: pluginApi?.tr("settings.buttonSpacing.desc") + from: 10; to: 80; stepSize: 5; snapAlways: true + value: root.editBtnSpacing + text: root.editBtnSpacing + "px" + defaultValue: defaults.buttonSpacing ?? 20 + showReset: true + onMoved: v => { root.editBtnSpacing = v; } + } + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.buttonOpacity.label") + from: 0.1; to: 1.0; stepSize: 0.05; snapAlways: true + value: root.editBtnOpacity + text: Math.round(root.editBtnOpacity * 100) + "%" + defaultValue: defaults.buttonOpacity ?? 0.50 + showReset: true + onMoved: v => { root.editBtnOpacity = v; } + } + + NColorChoice { + label: pluginApi?.tr("settings.hoverColor.label") + description: pluginApi?.tr("settings.hoverColor.desc") + currentKey: root.editHoverColor + onSelected: key => { root.editHoverColor = key; } + defaultValue: defaults.hoverColor || "primary" + } + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.hoverDesaturation.label") + description: pluginApi?.tr("settings.hoverDesaturation.desc") + from: 0; to: 85; stepSize: 5; snapAlways: true + value: root.editHoverDesaturation * 100 + text: Math.round(root.editHoverDesaturation * 100) + "%" + defaultValue: defaults.hoverDesaturation ?? 0.0 + showReset: true + onMoved: v => { root.editHoverDesaturation = v / 100; } + } + + NDivider { Layout.fillWidth: true; Layout.topMargin: Style.marginS; Layout.bottomMargin: Style.marginS } + + NText { + text: pluginApi?.tr("section.background") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.useOverlay.label") + description: pluginApi?.tr("settings.useOverlay.desc") + checked: root.editUseOverlay + onToggled: checked => { root.editUseOverlay = checked; } + defaultValue: defaults.useOverlay ?? true + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.editUseOverlay + spacing: Style.marginM + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.overlayBlur.label") + description: pluginApi?.tr("settings.overlayBlur.desc") + from: 0.0; to: 1.0; stepSize: 0.05; snapAlways: true + value: root.editOverlayBlur + text: Math.round(root.editOverlayBlur * 100) + "%" + defaultValue: defaults.overlayBlur ?? 0.75 + showReset: true + onMoved: v => { root.editOverlayBlur = v; } + } + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.overlayTint.label") + description: pluginApi?.tr("settings.overlayTint.desc") + from: 0.0; to: 1.0; stepSize: 0.05; snapAlways: true + value: root.editOverlayTint + text: Math.round(root.editOverlayTint * 100) + "%" + defaultValue: defaults.overlayTint ?? 0.6 + showReset: true + onMoved: v => { root.editOverlayTint = v; } + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: !root.editUseOverlay + spacing: Style.marginM + + NValueSlider { + Layout.fillWidth: true + label: pluginApi?.tr("settings.panelTint.label") + description: pluginApi?.tr("settings.panelTint.desc") + from: 0.0; to: 1.0; stepSize: 0.05; snapAlways: true + value: root.editPanelTint + text: Math.round(root.editPanelTint * 100) + "%" + defaultValue: defaults.panelTint ?? 0.7 + showReset: true + onMoved: v => { root.editPanelTint = v; } + } + } + + NDivider { Layout.fillWidth: true; Layout.topMargin: Style.marginS; Layout.bottomMargin: Style.marginS } + + NText { + text: pluginApi?.tr("section.animations") + pointSize: Style.fontSizeM + font.weight: Style.fontWeightBold + color: Color.mOnSurface + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.animationsEnabled.label") + description: pluginApi?.tr("settings.animationsEnabled.desc") + checked: root.editAnimationsEnabled + onToggled: checked => { root.editAnimationsEnabled = checked; } + defaultValue: defaults.animationsEnabled ?? true + } + + function saveSettings() { + if (!pluginApi) { + Logger.e("Somnus", "Cannot save settings: pluginApi is null") + return + } + pluginApi.pluginSettings.iconColor = root.editIconColor || "primary" + pluginApi.pluginSettings.hoverColor = root.editHoverColor || "primary" + pluginApi.pluginSettings.buttonWidth = root.editBtnWidth + pluginApi.pluginSettings.buttonHeight = root.editBtnHeight + pluginApi.pluginSettings.buttonSpacing = root.editBtnSpacing + pluginApi.pluginSettings.buttonOpacity = root.editBtnOpacity + pluginApi.pluginSettings.useOverlay = root.editUseOverlay + pluginApi.pluginSettings.overlayBlur = root.editOverlayBlur + pluginApi.pluginSettings.overlayTint = root.editOverlayTint + pluginApi.pluginSettings.panelTint = root.editPanelTint + pluginApi.pluginSettings.animationsEnabled = root.editAnimationsEnabled + pluginApi.pluginSettings.lockOnSuspend = root.editLockOnSuspend + pluginApi.pluginSettings.hoverDesaturation = root.editHoverDesaturation + pluginApi.saveSettings() + Logger.i("Somnus", "Settings saved successfully") + } +} diff --git a/somnus/SomnusWindow.qml b/somnus/SomnusWindow.qml new file mode 100644 index 000000000..aa2e55bb1 --- /dev/null +++ b/somnus/SomnusWindow.qml @@ -0,0 +1,391 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Wayland +import qs.Services.Compositor +import qs.Services.UI +import qs.Commons +import qs.Widgets + +PanelWindow { + id: win + + property var pluginApi: null + property ShellScreen screenRef: null + signal closeRequested() + + property int selectedIndex: -1 + property bool keyboardActive: false + + Timer { + id: debounceTimer + interval: 150 + repeat: false + onTriggered: { + win.keyboardActive = false; + } + } + + Component.onCompleted: { + Logger.i("Somnus", "SomnusWindow loaded. screenRef=" + (screenRef?.name ?? "null") + ", wlrOutput=" + (screenRef?.wlrOutput ?? "null") + ", pluginApi=" + (pluginApi !== null) + ", width=" + width + ", height=" + height) + selectedIndex = -1; + keyboardActive = false; + Qt.callLater(() => { focusHandler.forceActiveFocus(); }); + } + + function executeActionByIndex(idx) { + let actions = ["lock", "logout", "suspend", "hibernate", "reboot", "shutdown"]; + if (idx < 0 || idx >= actions.length) return; + let actionId = actions[idx]; + Logger.i("Somnus", "Executing " + actionId); + closeRequested(); + + switch (actionId) { + case "lock": + CompositorService.lock() + break + case "logout": + CompositorService.logout() + break + case "suspend": + if (pluginApi?.pluginSettings?.lockOnSuspend ?? true) + CompositorService.lockAndSuspend() + else + CompositorService.suspend() + break + case "hibernate": + CompositorService.hibernate() + break + case "reboot": + CompositorService.reboot() + break + case "shutdown": + CompositorService.shutdown() + break + } + } + + color: "transparent" + readonly property ShellScreen localScreen: screenRef ?? (Quickshell.screens?.length > 0 ? Quickshell.screens[0] : null) + screen: localScreen + + WlrLayershell.namespace: "somnus" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.anchors { + top: true + bottom: true + left: true + right: true + } + + readonly property string screenName: screenRef?.name ?? "" + readonly property bool useOverlay: pluginApi?.pluginSettings?.useOverlay ?? true + readonly property string wallpaperPath: screenName ? WallpaperService.getWallpaper(screenName) : "" + + readonly property real btnW: pluginApi?.pluginSettings?.buttonWidth ?? 420 + readonly property real btnH: pluginApi?.pluginSettings?.buttonHeight ?? 270 + readonly property real btnSp: pluginApi?.pluginSettings?.buttonSpacing ?? 30 + readonly property real gridW: btnW * 3 + btnSp * 2 + readonly property real gridH: btnH * 2 + btnSp * 1 + + readonly property real overlayBlur: pluginApi?.pluginSettings?.overlayBlur ?? 0.75 + readonly property real overlayTint: pluginApi?.pluginSettings?.overlayTint ?? 0.6 + readonly property real panelTint: pluginApi?.pluginSettings?.panelTint ?? 0.7 + readonly property bool animEnabled: pluginApi?.pluginSettings?.animationsEnabled ?? true + + Item { + id: focusHandler + anchors.fill: parent + focus: true + HoverHandler { + onPointChanged: { + if (win.keyboardActive) debounceTimer.restart(); + } + } + + Keys.onPressed: event => { + const columns = 3; + const total = 6; + let row = win.selectedIndex >= 0 ? Math.floor(win.selectedIndex / columns) : 0; + let col = win.selectedIndex >= 0 ? win.selectedIndex % columns : 0; + + if (event.key === Qt.Key_Escape) { + closeRequested(); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) { + if (win.selectedIndex >= 0 && win.selectedIndex < total) { + win.executeActionByIndex(win.selectedIndex); + } + event.accepted = true; + return; + } + + if (event.key >= Qt.Key_1 && event.key <= Qt.Key_6) { + debounceTimer.stop(); + win.keyboardActive = true; + let idx = event.key - Qt.Key_1; + win.selectedIndex = idx; + win.executeActionByIndex(idx); + event.accepted = true; + return; + } + + if (event.key === Qt.Key_Right) { + debounceTimer.stop(); + win.keyboardActive = true; + if (win.selectedIndex < 0) win.selectedIndex = 0; + else { + col = (col + 1) % columns; + win.selectedIndex = row * columns + col; + } + event.accepted = true; + } else if (event.key === Qt.Key_Left) { + debounceTimer.stop(); + win.keyboardActive = true; + if (win.selectedIndex < 0) win.selectedIndex = total - 1; + else { + col = (col - 1 + columns) % columns; + win.selectedIndex = row * columns + col; + } + event.accepted = true; + } else if (event.key === Qt.Key_Down) { + debounceTimer.stop(); + win.keyboardActive = true; + if (win.selectedIndex < 0) win.selectedIndex = 0; + else { + row = (row + 1) % 2; + win.selectedIndex = row * columns + col; + } + event.accepted = true; + } else if (event.key === Qt.Key_Up) { + debounceTimer.stop(); + win.keyboardActive = true; + if (win.selectedIndex < 0) win.selectedIndex = 0; + else { + row = (row - 1 + 2) % 2; + win.selectedIndex = row * columns + col; + } + event.accepted = true; + } + } + } + + // OVERLAY MODE + Item { + anchors.fill: parent + visible: useOverlay + + Rectangle { + anchors.fill: parent + color: "#000000" + } + + Image { + anchors.fill: parent + fillMode: Image.PreserveAspectCrop + source: wallpaperPath + cache: false + smooth: true + antialiasing: true + visible: source !== "" + + layer.enabled: overlayBlur > 0 + layer.smooth: false + layer.effect: MultiEffect { + blurEnabled: true + blur: overlayBlur + blurMax: 48 + } + + Rectangle { + anchors.fill: parent + color: "#000000" + visible: parent.source === "" + } + } + + Rectangle { + anchors.fill: parent + color: Color.mSurface + opacity: overlayTint + } + + Grid { + anchors.centerIn: parent + columns: 3 + spacing: btnSp + + Repeater { + model: ListModel { + ListElement { actionId: "lock"; iconImg: "icons/lock.svg" } + ListElement { actionId: "logout"; iconImg: "icons/logout.svg" } + ListElement { actionId: "suspend"; iconImg: "icons/sleep.svg" } + ListElement { actionId: "hibernate"; iconImg: "icons/hibernate.svg" } + ListElement { actionId: "reboot"; iconImg: "icons/restart.svg" } + ListElement { actionId: "shutdown"; iconImg: "icons/power.svg" } + } + delegate: btnDelegate + } + } + + MouseArea { + anchors.fill: parent + z: -1 + onClicked: closeRequested() + } + } + + // PANEL MODE + Rectangle { + anchors.fill: parent + visible: !useOverlay + color: Qt.rgba(0, 0, 0, panelTint) + + Grid { + anchors.centerIn: parent + columns: 3 + spacing: btnSp + + Repeater { + model: ListModel { + ListElement { actionId: "lock"; iconImg: "icons/lock.svg" } + ListElement { actionId: "logout"; iconImg: "icons/logout.svg" } + ListElement { actionId: "suspend"; iconImg: "icons/sleep.svg" } + ListElement { actionId: "hibernate"; iconImg: "icons/hibernate.svg" } + ListElement { actionId: "reboot"; iconImg: "icons/restart.svg" } + ListElement { actionId: "shutdown"; iconImg: "icons/power.svg" } + } + delegate: btnDelegate + } + } + + MouseArea { + anchors.fill: parent + z: -1 + onClicked: closeRequested() + } + } + + // BUTTON DELEGATE + Component { + id: btnDelegate + + Rectangle { + id: delegateRoot + width: btnW + height: btnH + radius: Style.iRadiusL + readonly property bool effectiveHover: !win.keyboardActive && btnMouseArea.containsMouse + readonly property bool isKeyboardFocused: win.keyboardActive && win.selectedIndex === index + readonly property bool isActiveState: effectiveHover || isKeyboardFocused + readonly property real hoverDesat: pluginApi?.pluginSettings?.hoverDesaturation ?? 0.0 + + function desaturateColor(c, amount) { + var newSat = Math.max(0.15, c.hslSaturation * (1 - Math.min(1, amount))); + return Qt.hsla(c.hslHue, newSat, c.hslLightness, c.hslAlpha); + } + + color: isActiveState + ? desaturateColor(Color.resolveColorKey(pluginApi?.pluginSettings?.hoverColor ?? "primary"), hoverDesat) + : Qt.rgba(0, 0, 0, pluginApi?.pluginSettings?.buttonOpacity ?? 0.75) + + Behavior on color { + ColorAnimation { duration: animEnabled ? 150 : 0; easing.type: Easing.OutCirc } + } + readonly property real initialScale: animEnabled ? 0.85 : 1.0 + readonly property real initialOpacity: animEnabled ? 0.0 : 1.0 + + property real entryScale: initialScale + property real entryOpacity: initialOpacity + + opacity: entryOpacity + transform: Scale { + origin.x: width / 2 + origin.y: height / 2 + xScale: entryScale + yScale: entryScale + } + + Behavior on entryScale { + NumberAnimation { duration: animEnabled ? 300 : 0; easing.type: Easing.OutBack; easing.overshoot: animEnabled ? 0.5 : 0 } + } + Behavior on entryOpacity { + NumberAnimation { duration: animEnabled ? 250 : 0; easing.type: Easing.OutQuad } + } + + Timer { + id: staggerTimer + interval: index * 80 + 50 + repeat: false + onTriggered: { + delegateRoot.entryScale = 1.0 + delegateRoot.entryOpacity = 1.0 + } + } + + Component.onCompleted: { + Logger.i("Somnus", "Delegate created: index=" + index + ", actionId=" + actionId + ", width=" + width + ", height=" + height + ", opacity=" + opacity) + if (animEnabled) staggerTimer.start() + } + + scale: isActiveState ? (animEnabled ? 1.05 : 1.0) : 1.0 + Behavior on scale { + NumberAnimation { duration: animEnabled ? 300 : 0; easing.type: animEnabled ? Easing.OutBack : Easing.Linear; easing.overshoot: animEnabled ? 0.5 : 0 } + } + + Column { + anchors.centerIn: parent + spacing: Style.marginXL + + Image { + id: btnIcon + source: iconImg + width: 80 + height: 80 + fillMode: Image.PreserveAspectFit + anchors.horizontalCenter: parent.horizontalCenter + smooth: true + mipmap: true + antialiasing: true + + Behavior on scale { + NumberAnimation { duration: animEnabled ? 300 : 0; easing.type: animEnabled ? Easing.OutBack : Easing.Linear; easing.overshoot: animEnabled ? 0.6 : 0 } + } + + scale: isActiveState ? (animEnabled ? 1.15 : 1.0) : 1.0 + } + + NText { + text: pluginApi?.tr("button." + actionId) + color: "#FFFFFF" + font.family: "JetBrains Mono NFM" + font.pixelSize: 24 + anchors.horizontalCenter: parent.horizontalCenter + + Behavior on color { ColorAnimation { duration: animEnabled ? 150 : 0; easing.type: Easing.OutCirc } } + } + } + + MouseArea { + id: btnMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onEntered: { + if (!win.keyboardActive) win.selectedIndex = index; + } + + onClicked: { + win.selectedIndex = index; + win.executeActionByIndex(index); + } + } + } + } +} diff --git a/somnus/i18n/en.json b/somnus/i18n/en.json new file mode 100644 index 000000000..f5123e0df --- /dev/null +++ b/somnus/i18n/en.json @@ -0,0 +1,78 @@ +{ + "header": { + "title": "Somnus Power Menu", + "desc": "Customize the appearance of the power menu" + }, + "section": { + "barButton": "Bar button", + "behavior": "Behavior", + "buttons": "Buttons", + "background": "Background", + "animations": "Animations" + }, + "menu": { + "settings": "Widget settings" + }, + "widget": { + "tooltip": "Somnus" + }, + "button": { + "lock": "Lock", + "logout": "Logout", + "suspend": "Suspend", + "hibernate": "Hibernate", + "reboot": "Reboot", + "shutdown": "Shutdown" + }, + "settings": { + "iconColor": { + "label": "Icon color", + "desc": "Color of the power icon in the bar" + }, + "hoverColor": { + "label": "Hover color", + "desc": "Color when hovering over a button" + }, + "buttonWidth": { + "label": "Button width" + }, + "buttonHeight": { + "label": "Button height" + }, + "buttonSpacing": { + "label": "Button spacing", + "desc": "Distance between buttons in the grid" + }, + "buttonOpacity": { + "label": "Button opacity" + }, + "useOverlay": { + "label": "Screen overlay mode", + "desc": "When enabled, the menu covers the full screen. When disabled, the menu appears as a centered panel." + }, + "overlayBlur": { + "label": "Overlay blur", + "desc": "Blur intensity when the menu covers the full screen." + }, + "overlayTint": { + "label": "Overlay tint", + "desc": "Darkness of the overlay on top of the blurred background." + }, + "panelTint": { + "label": "Panel tint", + "desc": "Darkness of the centered panel background." + }, + "animationsEnabled": { + "label": "Button animations", + "desc": "When disabled, buttons appear instantly without entry or hover animations." + }, + "lockOnSuspend": { + "label": "Lock on suspend", + "desc": "When enabled, the screen locks before suspending. When disabled, the system suspends without locking." + }, + "hoverDesaturation": { + "label": "Hover desaturation", + "desc": "Reduces color intensity on hover. Higher values make the hover color appear more gray." + } + } +} diff --git a/somnus/i18n/es.json b/somnus/i18n/es.json new file mode 100644 index 000000000..9accba3a6 --- /dev/null +++ b/somnus/i18n/es.json @@ -0,0 +1,78 @@ +{ + "header": { + "title": "Somnus Menú de Energía", + "desc": "Personaliza la apariencia del menú de energía" + }, + "section": { + "barButton": "Botón de la barra", + "behavior": "Comportamiento", + "buttons": "Botones", + "background": "Fondo", + "animations": "Animaciones" + }, + "menu": { + "settings": "Configuración del widget" + }, + "widget": { + "tooltip": "Somnus" + }, + "button": { + "lock": "Bloquear", + "logout": "Cerrar sesión", + "suspend": "Suspender", + "hibernate": "Hibernar", + "reboot": "Reiniciar", + "shutdown": "Apagar" + }, + "settings": { + "iconColor": { + "label": "Color del ícono", + "desc": "Color del ícono de encendido en la barra" + }, + "hoverColor": { + "label": "Color al hover", + "desc": "Color al pasar el cursor sobre un botón" + }, + "buttonWidth": { + "label": "Ancho del botón" + }, + "buttonHeight": { + "label": "Alto del botón" + }, + "buttonSpacing": { + "label": "Espaciado entre botones", + "desc": "Distancia entre los botones en la cuadrícula" + }, + "buttonOpacity": { + "label": "Opacidad del botón" + }, + "useOverlay": { + "label": "Modo superposición", + "desc": "Cuando está activado, el menú cubre toda la pantalla. Cuando está desactivado, aparece como un panel centrado." + }, + "overlayBlur": { + "label": "Desenfoque de superposición", + "desc": "Intensidad del desenfoque cuando el menú cubre toda la pantalla." + }, + "overlayTint": { + "label": "Tinte de superposición", + "desc": "Oscuridad de la superposición sobre el fondo borroso." + }, + "panelTint": { + "label": "Tinte del panel", + "desc": "Oscuridad del fondo del panel centrado." + }, + "animationsEnabled": { + "label": "Animaciones de botones", + "desc": "Cuando está desactivado, los botones aparecen instantáneamente sin animaciones." + }, + "lockOnSuspend": { + "label": "Bloquear al suspender", + "desc": "Cuando está activado, la pantalla se bloquea antes de suspender. Cuando está desactivado, el sistema suspende sin bloquear." + }, + "hoverDesaturation": { + "label": "Desaturación del hover", + "desc": "Reduce la intensidad del color del hover. Valores más altos vuelven el color más grisáceo." + } + } +} diff --git a/somnus/icons/hibernate.svg b/somnus/icons/hibernate.svg new file mode 100644 index 000000000..72b9a19a3 --- /dev/null +++ b/somnus/icons/hibernate.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/somnus/icons/lock.svg b/somnus/icons/lock.svg new file mode 100644 index 000000000..8116e0363 --- /dev/null +++ b/somnus/icons/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/somnus/icons/logout.svg b/somnus/icons/logout.svg new file mode 100644 index 000000000..8372f87ae --- /dev/null +++ b/somnus/icons/logout.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/somnus/icons/power.svg b/somnus/icons/power.svg new file mode 100644 index 000000000..1ce01fdce --- /dev/null +++ b/somnus/icons/power.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/somnus/icons/restart.svg b/somnus/icons/restart.svg new file mode 100644 index 000000000..20a5db215 --- /dev/null +++ b/somnus/icons/restart.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/somnus/icons/sleep.svg b/somnus/icons/sleep.svg new file mode 100644 index 000000000..77d688b12 --- /dev/null +++ b/somnus/icons/sleep.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/somnus/manifest.json b/somnus/manifest.json new file mode 100644 index 000000000..be4768884 --- /dev/null +++ b/somnus/manifest.json @@ -0,0 +1,36 @@ +{ + "id": "somnus", + "name": "somnus", + "version": "1.0.0", + "minNoctaliaVersion": "4.7.0", + "author": "yhordi45", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "A full-screen power menu for Noctalia Shell, inspired by the wlogout design.", + "tags": ["Bar", "Panel", "Hyprland", "System"], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "iconColor": "primary", + "hoverColor": "primary", + "buttonWidth": 470, + "buttonHeight": 280, + "buttonSpacing": 20, + "buttonOpacity": 0.50, + "useOverlay": true, + "overlayBlur": 0.75, + "overlayTint": 0.6, + "panelTint": 0.7, + "animationsEnabled": true, + "lockOnSuspend": true, + "hoverDesaturation": 0.0 + } + } +} diff --git a/somnus/preview.png b/somnus/preview.png new file mode 100644 index 000000000..cb162a183 Binary files /dev/null and b/somnus/preview.png differ