diff --git a/README.md b/README.md index 2e29645..2315866 100644 --- a/README.md +++ b/README.md @@ -131,15 +131,7 @@ programs.nova-shell.modules = { ``` Each module is an object with `enable` (default `true`) and optional extra -settings. For a full list of all options with types and defaults, build the docs: - -```bash -nix build .#docs -# markdown: result/options.md -# html: result/index.html (open in browser) -``` - - Full list: `workspaces`, `tray`, `windowTitle`, `clock`, +settings. Full list: `workspaces`, `tray`, `windowTitle`, `clock`, `notifications`, `mpris`, `volume`, `bluetooth`, `backlight`, `network`, `powerProfile`, `idleInhibitor`, `weather`, `temperature`, `gpu`, `cpu`, `memory`, `disk`, `battery`, `power`, `backgroundOverlay`, `overviewBackdrop`, `lock`. diff --git a/nix/hm-module.nix b/nix/hm-module.nix index f20a3eb..8480617 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -126,11 +126,6 @@ in default = true; description = "Show weather summary on the lock screen."; }; - threatEffect = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Show red vignette and chromatic aberration on failed password attempts."; - }; }; notifications = moduleOpt "notifications" { timeout = lib.mkOption { diff --git a/shell/applets/qmldir b/shell/applets/qmldir index bf26e90..f74b6f7 100644 --- a/shell/applets/qmldir +++ b/shell/applets/qmldir @@ -10,7 +10,6 @@ HexWaveBackground 1.0 HexWaveBackground.qml MemoryApplet 1.0 MemoryApplet.qml MprisApplet 1.0 MprisApplet.qml NetworkApplet 1.0 NetworkApplet.qml -NotifApplet 1.0 NotifApplet.qml TemperatureApplet 1.0 TemperatureApplet.qml VolumeApplet 1.0 VolumeApplet.qml WeatherApplet 1.0 WeatherApplet.qml diff --git a/shell/lock/LockSurface.qml b/shell/lock/LockSurface.qml index f4ba93f..ef46721 100644 --- a/shell/lock/LockSurface.qml +++ b/shell/lock/LockSurface.qml @@ -16,8 +16,7 @@ WlSessionLockSurface { property real _unlockFade: 1 // Threat level: 0.0-1.0 based on fail count (5 fails = max) - readonly property bool _threatEnabled: S.Modules.lock.threatEffect ?? true - readonly property real _threat: _threatEnabled ? Math.min(1.0, root.auth.failCount / 5) : 0 + readonly property real _threat: Math.min(1.0, root.auth.failCount / 5) property real _heartbeat: 0 // All visual content wrapped for threat shader @@ -322,7 +321,7 @@ WlSessionLockSurface { SequentialAnimation { id: _heartbeatAnim loops: Animation.Infinite - running: root._threatEnabled && root.auth.failCount >= 3 && root.lock.secure + running: root.auth.failCount >= 3 && root.lock.secure // Systole (sharp spike) NumberAnimation { diff --git a/shell/modules/Bar.qml b/shell/modules/Bar.qml index 0458381..fd86f92 100644 --- a/shell/modules/Bar.qml +++ b/shell/modules/Bar.qml @@ -114,6 +114,7 @@ PanelWindow { visible: S.Modules.clock.enable } M.NotificationsModule { + bar: bar visible: S.Modules.notifications.enable } } diff --git a/shell/applets/NotifApplet.qml b/shell/modules/NotifCenter.qml similarity index 77% rename from shell/applets/NotifApplet.qml rename to shell/modules/NotifCenter.qml index 7454b1f..27180a8 100644 --- a/shell/applets/NotifApplet.qml +++ b/shell/modules/NotifCenter.qml @@ -1,26 +1,81 @@ import QtQuick import Quickshell +import "." as M import "../services" as S -import "../modules" as M -Column { - id: root +M.HoverPanel { + id: menuWindow - required property color accentColor - required property int contentWidth + popupMode: true + contentWidth: 350 - function cascadeDismiss() { - _cascadeDismiss(); + // Header: title + clear all + DND toggle + Item { + width: menuWindow.contentWidth + height: 32 + + Text { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: "Notifications" + color: S.Theme.base05 + font.pixelSize: S.Theme.fontSize + 1 + font.family: S.Theme.fontFamily + font.bold: true + } + + Row { + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + // DND toggle + Text { + text: S.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C" + color: S.NotifService.dnd ? S.Theme.base09 : S.Theme.base04 + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.iconFontFamily + anchors.verticalCenter: parent.verticalCenter + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: S.NotifService.toggleDnd() + } + } + + // Clear all + Text { + text: "\uF1F8" + color: clearArea.containsMouse ? S.Theme.base08 : S.Theme.base04 + font.pixelSize: S.Theme.fontSize + font.family: S.Theme.iconFontFamily + anchors.verticalCenter: parent.verticalCenter + visible: S.NotifService.count > 0 + + MouseArea { + id: clearArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: menuWindow._cascadeDismiss() + } + } + } } - // ── Cascade dismiss logic ─────────────────────────────────────────── property var _pendingDismissIds: [] + + // Collapsed groups set — reassign to trigger reactivity property var _collapsedGroups: ({}) + property real _savedScrollY: 0 property bool _restoringScroll: false function _toggleCollapse(appName) { - _savedScrollY = _notifList.contentY; + _savedScrollY = notifList.contentY; _restoringScroll = true; const next = Object.assign({}, _collapsedGroups); if (next[appName]) @@ -30,6 +85,7 @@ Column { _collapsedGroups = next; } + // Group notifications by appName, sorted by max urgency desc then most recent time desc readonly property var _groups: { const map = {}; for (const n of S.NotifService.list) { @@ -55,6 +111,7 @@ Column { }); } + // Flat model: group header followed by its notifications (omitted when collapsed) readonly property var _flatModel: { const arr = []; for (const g of _groups) { @@ -78,6 +135,7 @@ Column { return arr; } + // Collect visible (non-dismissing) notif delegates, optionally filtered by appName function _getVisibleNotifDelegates(appName) { const result = []; for (let i = 0; i < _flatModel.length; i++) { @@ -86,7 +144,7 @@ Column { continue; if (appName !== undefined && item.data.appName !== appName) continue; - const d = _notifList.itemAtIndex(i); + const d = notifList.itemAtIndex(i); if (d && d._type === "notif" && d._notif?.state !== "dismissing") result.push(d); } @@ -100,7 +158,7 @@ Column { return; } for (let i = 0; i < visibles.length; i++) { - _cascadeTimer.createObject(root, { + _cascadeTimer.createObject(menuWindow, { _target: visibles[i], _delay: i * 60, _isLast: i === visibles.length - 1 @@ -140,7 +198,7 @@ Column { if (_target && _target.dismissVisualOnly) _target.dismissVisualOnly(); if (_isLast) - root._bulkTimer.createObject(root, {}); + _bulkTimer.createObject(menuWindow, {}); destroy(); } } @@ -148,28 +206,36 @@ Column { property Component _bulkTimer: Component { Timer { - interval: 400 + interval: 400 // swipe (200) + collapse (150) + margin running: true onTriggered: { - root._finishCascade(); + menuWindow._finishCascade(); destroy(); } } } - // ── Notification list ──────────────────────────────────────────────── + // Separator + Rectangle { + width: menuWindow.contentWidth - 16 + height: 1 + anchors.horizontalCenter: parent.horizontalCenter + color: S.Theme.base03 + } + + // Notification list (scrollable) ListView { - id: _notifList - width: root.contentWidth + id: notifList + width: menuWindow.contentWidth height: Math.min(contentHeight, 60 * (S.Modules.notifications.maxVisible || 10)) clip: true boundsBehavior: Flickable.StopAtBounds - model: root._flatModel + model: menuWindow._flatModel onContentHeightChanged: { - if (root._restoringScroll) { - contentY = Math.min(root._savedScrollY, Math.max(0, contentHeight - height)); - root._restoringScroll = false; + if (menuWindow._restoringScroll) { + contentY = Math.min(menuWindow._savedScrollY, Math.max(0, contentHeight - height)); + menuWindow._restoringScroll = false; } } @@ -181,7 +247,7 @@ Column { readonly property string _type: modelData.type readonly property var _notif: _type === "notif" ? modelData.data : null - width: root.contentWidth + width: menuWindow.contentWidth height: _displayTargetHeight * _heightScale clip: true opacity: 0 @@ -192,6 +258,7 @@ Column { return _notifCard.implicitHeight; } + // Animated version of _targetHeight — smoothly transitions header height on collapse property real _displayTargetHeight: _targetHeight Behavior on _displayTargetHeight { NumberAnimation { @@ -238,6 +305,7 @@ Column { id: _headerHover } + // Tap target for collapse — covers header row only, excludes dismiss button Item { anchors.left: parent.left anchors.right: _groupDismissBtn.left @@ -248,7 +316,7 @@ Column { cursorShape: Qt.PointingHandCursor } TapHandler { - onTapped: root._toggleCollapse(notifDelegate.modelData.appName) + onTapped: menuWindow._toggleCollapse(notifDelegate.modelData.appName) } } @@ -274,6 +342,7 @@ Column { asynchronous: true } + // Collapse chevron Text { id: _chevron anchors.right: _groupDismissBtn.left @@ -288,6 +357,7 @@ Column { opacity: _headerHover.hovered ? 1 : 0 } + // App name Text { anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left anchors.leftMargin: _headerIcon.visible ? 6 : 10 @@ -304,6 +374,7 @@ Column { elide: Text.ElideRight } + // Dismiss button — opacity-hidden when header not hovered Text { id: _groupDismissBtn anchors.right: parent.right @@ -325,11 +396,12 @@ Column { TapHandler { onTapped: { if (notifDelegate._type === "header") - root._cascadeGroupDismiss(notifDelegate.modelData.appName); + menuWindow._cascadeGroupDismiss(notifDelegate.modelData.appName); } } } + // Collapsed preview: one line per notification summary Repeater { model: (notifDelegate._type === "header" && notifDelegate.modelData.collapsed) ? notifDelegate.modelData.summaries : [] @@ -353,6 +425,7 @@ Column { } // ---- Individual notification ---- + M.NotifCard { id: _notifCard visible: notifDelegate._type === "notif" @@ -372,7 +445,7 @@ Column { NumberAnimation { target: notifDelegate property: "x" - to: root.contentWidth + to: menuWindow.contentWidth duration: 200 easing.type: Easing.InCubic } @@ -404,7 +477,7 @@ Column { // Empty state Text { visible: S.NotifService.count === 0 - width: root.contentWidth + width: menuWindow.contentWidth height: 48 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter diff --git a/shell/modules/NotificationsModule.qml b/shell/modules/NotificationsModule.qml index 7df4b2b..4783f44 100644 --- a/shell/modules/NotificationsModule.qml +++ b/shell/modules/NotificationsModule.qml @@ -3,32 +3,21 @@ import Quickshell import Quickshell.Services.Notifications import "." as M import "../services" as S -import "../applets" as C M.BarSection { id: root spacing: S.Theme.moduleSpacing - tooltip: "" + tooltip: { + const parts = [S.NotifService.count + " notification" + (S.NotifService.count !== 1 ? "s" : "")]; + if (S.NotifService.dnd) + parts.push("Do not disturb"); + return parts.join("\n"); + } + + required property var bar readonly property bool hasUrgent: S.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed") - property bool _pinned: false - readonly property bool _anyHover: root._hovered || hoverPanel.panelHovered - readonly property bool _showPanel: _anyHover || _pinned - - on_AnyHoverChanged: { - if (_anyHover) - _unpinTimer.stop(); - else if (_pinned) - _unpinTimer.start(); - } - - Timer { - id: _unpinTimer - interval: 500 - onTriggered: root._pinned = false - } - M.BarIcon { icon: { if (S.NotifService.dnd) @@ -37,18 +26,12 @@ M.BarSection { } color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor anchors.verticalCenter: parent.verticalCenter - TapHandler { - onTapped: root._pinned = !root._pinned - } } M.BarLabel { id: countLabel label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : "" color: root.hasUrgent ? S.Theme.base08 : root.accentColor anchors.verticalCenter: parent.verticalCenter - TapHandler { - onTapped: root._pinned = !root._pinned - } transform: Scale { id: countScale @@ -85,66 +68,26 @@ M.BarSection { } } - // Right-click DND quick toggle + TapHandler { + acceptedButtons: Qt.LeftButton + onTapped: { + centerLoader.active = !centerLoader.active; + M.FlyoutState.visible = false; + } + } TapHandler { acceptedButtons: Qt.RightButton onTapped: S.NotifService.toggleDnd() } - M.HoverPanel { - id: hoverPanel - showPanel: root._showPanel - screen: QsWindow.window?.screen ?? null - anchorItem: root - accentColor: root.accentColor - panelNamespace: "nova-notifications" - panelTitle: "Notifications" - contentWidth: 350 - titleActionsComponent: Component { - Row { - spacing: 8 - - // DND toggle - Text { - text: S.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C" - color: S.NotifService.dnd ? S.Theme.base09 : S.Theme.base04 - font.pixelSize: S.Theme.fontSize - font.family: S.Theme.iconFontFamily - anchors.verticalCenter: parent.verticalCenter - - HoverHandler { - cursorShape: Qt.PointingHandCursor - } - TapHandler { - onTapped: S.NotifService.toggleDnd() - } - } - - // Clear all - Text { - text: "\uF1F8" - color: _clearHover.hovered ? S.Theme.base08 : S.Theme.base04 - font.pixelSize: S.Theme.fontSize - font.family: S.Theme.iconFontFamily - anchors.verticalCenter: parent.verticalCenter - visible: S.NotifService.count > 0 - - HoverHandler { - id: _clearHover - cursorShape: Qt.PointingHandCursor - } - TapHandler { - onTapped: _notifApplet.cascadeDismiss() - } - } - } - } - - C.NotifApplet { - id: _notifApplet - width: hoverPanel.contentWidth - contentWidth: hoverPanel.contentWidth + LazyLoader { + id: centerLoader + active: false + M.NotifCenter { accentColor: root.accentColor + screen: root.bar.screen + anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0) + onDismissed: centerLoader.active = false } } } diff --git a/shell/modules/qmldir b/shell/modules/qmldir index 3a8eee3..b4a55ee 100644 --- a/shell/modules/qmldir +++ b/shell/modules/qmldir @@ -20,6 +20,7 @@ MemoryModule 1.0 MemoryModule.qml MprisModule 1.0 MprisModule.qml NetworkModule 1.0 NetworkModule.qml NotifCard 1.0 NotifCard.qml +NotifCenter 1.0 NotifCenter.qml NotifPopup 1.0 NotifPopup.qml NotificationsModule 1.0 NotificationsModule.qml OverviewBackdrop 1.0 OverviewBackdrop.qml diff --git a/shell/services/Modules.qml b/shell/services/Modules.qml index 897a38e..f9ea948 100644 --- a/shell/services/Modules.qml +++ b/shell/services/Modules.qml @@ -99,8 +99,7 @@ QtObject { notifications: true, mpris: true, volume: true, - weather: true, - threatEffect: true + weather: true }) property var statsDaemon: ({ interval: -1