extract NotifApplet, fold NotifCenter into NotificationsModule with hover+pin

This commit is contained in:
Damocles 2026-04-22 22:44:39 +02:00
parent 472b4e62ab
commit c1588ceb5e
5 changed files with 106 additions and 123 deletions

View file

@ -1,81 +1,26 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import "." as M
import "../services" as S import "../services" as S
import "../modules" as M
M.HoverPanel { Column {
id: menuWindow id: root
popupMode: true required property color accentColor
contentWidth: 350 required property int contentWidth
// Header: title + clear all + DND toggle function cascadeDismiss() {
Item { _cascadeDismiss();
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: [] property var _pendingDismissIds: []
// Collapsed groups set reassign to trigger reactivity
property var _collapsedGroups: ({}) property var _collapsedGroups: ({})
property real _savedScrollY: 0 property real _savedScrollY: 0
property bool _restoringScroll: false property bool _restoringScroll: false
function _toggleCollapse(appName) { function _toggleCollapse(appName) {
_savedScrollY = notifList.contentY; _savedScrollY = _notifList.contentY;
_restoringScroll = true; _restoringScroll = true;
const next = Object.assign({}, _collapsedGroups); const next = Object.assign({}, _collapsedGroups);
if (next[appName]) if (next[appName])
@ -85,7 +30,6 @@ M.HoverPanel {
_collapsedGroups = next; _collapsedGroups = next;
} }
// Group notifications by appName, sorted by max urgency desc then most recent time desc
readonly property var _groups: { readonly property var _groups: {
const map = {}; const map = {};
for (const n of S.NotifService.list) { for (const n of S.NotifService.list) {
@ -111,7 +55,6 @@ M.HoverPanel {
}); });
} }
// Flat model: group header followed by its notifications (omitted when collapsed)
readonly property var _flatModel: { readonly property var _flatModel: {
const arr = []; const arr = [];
for (const g of _groups) { for (const g of _groups) {
@ -135,7 +78,6 @@ M.HoverPanel {
return arr; return arr;
} }
// Collect visible (non-dismissing) notif delegates, optionally filtered by appName
function _getVisibleNotifDelegates(appName) { function _getVisibleNotifDelegates(appName) {
const result = []; const result = [];
for (let i = 0; i < _flatModel.length; i++) { for (let i = 0; i < _flatModel.length; i++) {
@ -144,7 +86,7 @@ M.HoverPanel {
continue; continue;
if (appName !== undefined && item.data.appName !== appName) if (appName !== undefined && item.data.appName !== appName)
continue; continue;
const d = notifList.itemAtIndex(i); const d = _notifList.itemAtIndex(i);
if (d && d._type === "notif" && d._notif?.state !== "dismissing") if (d && d._type === "notif" && d._notif?.state !== "dismissing")
result.push(d); result.push(d);
} }
@ -158,7 +100,7 @@ M.HoverPanel {
return; return;
} }
for (let i = 0; i < visibles.length; i++) { for (let i = 0; i < visibles.length; i++) {
_cascadeTimer.createObject(menuWindow, { _cascadeTimer.createObject(root, {
_target: visibles[i], _target: visibles[i],
_delay: i * 60, _delay: i * 60,
_isLast: i === visibles.length - 1 _isLast: i === visibles.length - 1
@ -198,7 +140,7 @@ M.HoverPanel {
if (_target && _target.dismissVisualOnly) if (_target && _target.dismissVisualOnly)
_target.dismissVisualOnly(); _target.dismissVisualOnly();
if (_isLast) if (_isLast)
_bulkTimer.createObject(menuWindow, {}); root._bulkTimer.createObject(root, {});
destroy(); destroy();
} }
} }
@ -206,36 +148,28 @@ M.HoverPanel {
property Component _bulkTimer: Component { property Component _bulkTimer: Component {
Timer { Timer {
interval: 400 // swipe (200) + collapse (150) + margin interval: 400
running: true running: true
onTriggered: { onTriggered: {
menuWindow._finishCascade(); root._finishCascade();
destroy(); destroy();
} }
} }
} }
// Separator // Notification list
Rectangle {
width: menuWindow.contentWidth - 16
height: 1
anchors.horizontalCenter: parent.horizontalCenter
color: S.Theme.base03
}
// Notification list (scrollable)
ListView { ListView {
id: notifList id: _notifList
width: menuWindow.contentWidth width: root.contentWidth
height: Math.min(contentHeight, 60 * (S.Modules.notifications.maxVisible || 10)) height: Math.min(contentHeight, 60 * (S.Modules.notifications.maxVisible || 10))
clip: true clip: true
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
model: menuWindow._flatModel model: root._flatModel
onContentHeightChanged: { onContentHeightChanged: {
if (menuWindow._restoringScroll) { if (root._restoringScroll) {
contentY = Math.min(menuWindow._savedScrollY, Math.max(0, contentHeight - height)); contentY = Math.min(root._savedScrollY, Math.max(0, contentHeight - height));
menuWindow._restoringScroll = false; root._restoringScroll = false;
} }
} }
@ -247,7 +181,7 @@ M.HoverPanel {
readonly property string _type: modelData.type readonly property string _type: modelData.type
readonly property var _notif: _type === "notif" ? modelData.data : null readonly property var _notif: _type === "notif" ? modelData.data : null
width: menuWindow.contentWidth width: root.contentWidth
height: _displayTargetHeight * _heightScale height: _displayTargetHeight * _heightScale
clip: true clip: true
opacity: 0 opacity: 0
@ -258,7 +192,6 @@ M.HoverPanel {
return _notifCard.implicitHeight; return _notifCard.implicitHeight;
} }
// Animated version of _targetHeight smoothly transitions header height on collapse
property real _displayTargetHeight: _targetHeight property real _displayTargetHeight: _targetHeight
Behavior on _displayTargetHeight { Behavior on _displayTargetHeight {
NumberAnimation { NumberAnimation {
@ -305,7 +238,6 @@ M.HoverPanel {
id: _headerHover id: _headerHover
} }
// Tap target for collapse covers header row only, excludes dismiss button
Item { Item {
anchors.left: parent.left anchors.left: parent.left
anchors.right: _groupDismissBtn.left anchors.right: _groupDismissBtn.left
@ -316,7 +248,7 @@ M.HoverPanel {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
} }
TapHandler { TapHandler {
onTapped: menuWindow._toggleCollapse(notifDelegate.modelData.appName) onTapped: root._toggleCollapse(notifDelegate.modelData.appName)
} }
} }
@ -342,7 +274,6 @@ M.HoverPanel {
asynchronous: true asynchronous: true
} }
// Collapse chevron
Text { Text {
id: _chevron id: _chevron
anchors.right: _groupDismissBtn.left anchors.right: _groupDismissBtn.left
@ -357,7 +288,6 @@ M.HoverPanel {
opacity: _headerHover.hovered ? 1 : 0 opacity: _headerHover.hovered ? 1 : 0
} }
// App name
Text { Text {
anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left
anchors.leftMargin: _headerIcon.visible ? 6 : 10 anchors.leftMargin: _headerIcon.visible ? 6 : 10
@ -374,7 +304,6 @@ M.HoverPanel {
elide: Text.ElideRight elide: Text.ElideRight
} }
// Dismiss button opacity-hidden when header not hovered
Text { Text {
id: _groupDismissBtn id: _groupDismissBtn
anchors.right: parent.right anchors.right: parent.right
@ -396,12 +325,11 @@ M.HoverPanel {
TapHandler { TapHandler {
onTapped: { onTapped: {
if (notifDelegate._type === "header") if (notifDelegate._type === "header")
menuWindow._cascadeGroupDismiss(notifDelegate.modelData.appName); root._cascadeGroupDismiss(notifDelegate.modelData.appName);
} }
} }
} }
// Collapsed preview: one line per notification summary
Repeater { Repeater {
model: (notifDelegate._type === "header" && notifDelegate.modelData.collapsed) ? notifDelegate.modelData.summaries : [] model: (notifDelegate._type === "header" && notifDelegate.modelData.collapsed) ? notifDelegate.modelData.summaries : []
@ -425,7 +353,6 @@ M.HoverPanel {
} }
// ---- Individual notification ---- // ---- Individual notification ----
M.NotifCard { M.NotifCard {
id: _notifCard id: _notifCard
visible: notifDelegate._type === "notif" visible: notifDelegate._type === "notif"
@ -445,7 +372,7 @@ M.HoverPanel {
NumberAnimation { NumberAnimation {
target: notifDelegate target: notifDelegate
property: "x" property: "x"
to: menuWindow.contentWidth to: root.contentWidth
duration: 200 duration: 200
easing.type: Easing.InCubic easing.type: Easing.InCubic
} }
@ -477,7 +404,7 @@ M.HoverPanel {
// Empty state // Empty state
Text { Text {
visible: S.NotifService.count === 0 visible: S.NotifService.count === 0
width: menuWindow.contentWidth width: root.contentWidth
height: 48 height: 48
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter

View file

@ -10,6 +10,7 @@ HexWaveBackground 1.0 HexWaveBackground.qml
MemoryApplet 1.0 MemoryApplet.qml MemoryApplet 1.0 MemoryApplet.qml
MprisApplet 1.0 MprisApplet.qml MprisApplet 1.0 MprisApplet.qml
NetworkApplet 1.0 NetworkApplet.qml NetworkApplet 1.0 NetworkApplet.qml
NotifApplet 1.0 NotifApplet.qml
TemperatureApplet 1.0 TemperatureApplet.qml TemperatureApplet 1.0 TemperatureApplet.qml
VolumeApplet 1.0 VolumeApplet.qml VolumeApplet 1.0 VolumeApplet.qml
WeatherApplet 1.0 WeatherApplet.qml WeatherApplet 1.0 WeatherApplet.qml

View file

@ -114,7 +114,6 @@ PanelWindow {
visible: S.Modules.clock.enable visible: S.Modules.clock.enable
} }
M.NotificationsModule { M.NotificationsModule {
bar: bar
visible: S.Modules.notifications.enable visible: S.Modules.notifications.enable
} }
} }

View file

@ -3,21 +3,32 @@ import Quickshell
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import "." as M import "." as M
import "../services" as S import "../services" as S
import "../applets" as C
M.BarSection { M.BarSection {
id: root id: root
spacing: S.Theme.moduleSpacing 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") 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 { M.BarIcon {
icon: { icon: {
if (S.NotifService.dnd) if (S.NotifService.dnd)
@ -26,12 +37,18 @@ M.BarSection {
} }
color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
id: countLabel id: countLabel
label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : "" label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : ""
color: root.hasUrgent ? S.Theme.base08 : root.accentColor color: root.hasUrgent ? S.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
transform: Scale { transform: Scale {
id: countScale id: countScale
@ -68,26 +85,66 @@ M.BarSection {
} }
} }
TapHandler { // Right-click DND quick toggle
acceptedButtons: Qt.LeftButton
onTapped: {
centerLoader.active = !centerLoader.active;
M.FlyoutState.visible = false;
}
}
TapHandler { TapHandler {
acceptedButtons: Qt.RightButton acceptedButtons: Qt.RightButton
onTapped: S.NotifService.toggleDnd() onTapped: S.NotifService.toggleDnd()
} }
LazyLoader { M.HoverPanel {
id: centerLoader id: hoverPanel
active: false showPanel: root._showPanel
M.NotifCenter { 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
accentColor: root.accentColor accentColor: root.accentColor
screen: root.bar.screen
anchorX: root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0)
onDismissed: centerLoader.active = false
} }
} }
} }

View file

@ -20,7 +20,6 @@ MemoryModule 1.0 MemoryModule.qml
MprisModule 1.0 MprisModule.qml MprisModule 1.0 MprisModule.qml
NetworkModule 1.0 NetworkModule.qml NetworkModule 1.0 NetworkModule.qml
NotifCard 1.0 NotifCard.qml NotifCard 1.0 NotifCard.qml
NotifCenter 1.0 NotifCenter.qml
NotifPopup 1.0 NotifPopup.qml NotifPopup 1.0 NotifPopup.qml
NotificationsModule 1.0 NotificationsModule.qml NotificationsModule 1.0 NotificationsModule.qml
OverviewBackdrop 1.0 OverviewBackdrop.qml OverviewBackdrop 1.0 OverviewBackdrop.qml