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.
+
+
+
+## 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