extract NotifApplet, fold NotifCenter into NotificationsModule with hover+pin

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

View file

@ -0,0 +1,416 @@
import QtQuick
import Quickshell
import "../services" as S
import "../modules" as M
Column {
id: root
required property color accentColor
required property int contentWidth
function cascadeDismiss() {
_cascadeDismiss();
}
// ── Cascade dismiss logic ───────────────────────────────────────────
property var _pendingDismissIds: []
property var _collapsedGroups: ({})
property real _savedScrollY: 0
property bool _restoringScroll: false
function _toggleCollapse(appName) {
_savedScrollY = _notifList.contentY;
_restoringScroll = true;
const next = Object.assign({}, _collapsedGroups);
if (next[appName])
delete next[appName];
else
next[appName] = true;
_collapsedGroups = next;
}
readonly property var _groups: {
const map = {};
for (const n of S.NotifService.list) {
const key = n.appName || "";
if (!map[key])
map[key] = {
appName: key,
appIcon: n.appIcon,
notifs: [],
maxUrgency: 0,
maxTime: 0
};
map[key].notifs.push(n);
if (n.urgency > map[key].maxUrgency)
map[key].maxUrgency = n.urgency;
if (n.time > map[key].maxTime)
map[key].maxTime = n.time;
}
return Object.values(map).sort((a, b) => {
if (b.maxUrgency !== a.maxUrgency)
return b.maxUrgency - a.maxUrgency;
return b.maxTime - a.maxTime;
});
}
readonly property var _flatModel: {
const arr = [];
for (const g of _groups) {
const collapsed = !!_collapsedGroups[g.appName];
arr.push({
type: "header",
appName: g.appName,
appIcon: g.appIcon,
count: g.notifs.length,
collapsed: collapsed,
summaries: g.notifs.map(n => n.summary || "")
});
if (!collapsed) {
for (const n of g.notifs)
arr.push({
type: "notif",
data: n
});
}
}
return arr;
}
function _getVisibleNotifDelegates(appName) {
const result = [];
for (let i = 0; i < _flatModel.length; i++) {
const item = _flatModel[i];
if (item.type !== "notif")
continue;
if (appName !== undefined && item.data.appName !== appName)
continue;
const d = _notifList.itemAtIndex(i);
if (d && d._type === "notif" && d._notif?.state !== "dismissing")
result.push(d);
}
return result;
}
function _startCascade(visibles, ids) {
_pendingDismissIds = ids;
if (visibles.length === 0) {
_finishCascade();
return;
}
for (let i = 0; i < visibles.length; i++) {
_cascadeTimer.createObject(root, {
_target: visibles[i],
_delay: i * 60,
_isLast: i === visibles.length - 1
});
}
}
function _cascadeDismiss() {
if (S.NotifService.list.length === 0)
return;
const ids = S.NotifService.list.map(n => n.id);
_startCascade(_getVisibleNotifDelegates(), ids);
}
function _cascadeGroupDismiss(appName) {
const ids = S.NotifService.list.filter(n => n.appName === appName).map(n => n.id);
if (ids.length === 0)
return;
_startCascade(_getVisibleNotifDelegates(appName), ids);
}
function _finishCascade() {
const ids = _pendingDismissIds;
_pendingDismissIds = [];
for (const id of ids)
S.NotifService.dismiss(id);
}
property Component _cascadeTimer: Component {
Timer {
property var _target
property int _delay
property bool _isLast
interval: _delay
running: true
onTriggered: {
if (_target && _target.dismissVisualOnly)
_target.dismissVisualOnly();
if (_isLast)
root._bulkTimer.createObject(root, {});
destroy();
}
}
}
property Component _bulkTimer: Component {
Timer {
interval: 400
running: true
onTriggered: {
root._finishCascade();
destroy();
}
}
}
// ── Notification list ────────────────────────────────────────────────
ListView {
id: _notifList
width: root.contentWidth
height: Math.min(contentHeight, 60 * (S.Modules.notifications.maxVisible || 10))
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root._flatModel
onContentHeightChanged: {
if (root._restoringScroll) {
contentY = Math.min(root._savedScrollY, Math.max(0, contentHeight - height));
root._restoringScroll = false;
}
}
delegate: Item {
id: notifDelegate
required property var modelData
required property int index
readonly property string _type: modelData.type
readonly property var _notif: _type === "notif" ? modelData.data : null
width: root.contentWidth
height: _displayTargetHeight * _heightScale
clip: true
opacity: 0
readonly property real _targetHeight: {
if (_type === "header")
return modelData.collapsed ? (28 + modelData.count * (S.Theme.fontSize + 4)) : 28;
return _notifCard.implicitHeight;
}
property real _displayTargetHeight: _targetHeight
Behavior on _displayTargetHeight {
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
property real _heightScale: 1
property bool _skipDismiss: false
function dismiss() {
if (_type !== "notif" || _notif.state === "dismissing")
return;
_notif.beginDismiss();
_dismissAnim.start();
}
function dismissVisualOnly() {
if (_type !== "notif" || _notif.state === "dismissing")
return;
_notif.beginDismiss();
_skipDismiss = true;
_dismissAnim.start();
}
Component.onCompleted: fadeIn.start()
NumberAnimation {
id: fadeIn
target: notifDelegate
property: "opacity"
to: 1
duration: 150
easing.type: Easing.OutCubic
}
// ---- Group header ----
Item {
visible: notifDelegate._type === "header"
anchors.fill: parent
HoverHandler {
id: _headerHover
}
Item {
anchors.left: parent.left
anchors.right: _groupDismissBtn.left
anchors.top: parent.top
height: 28
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root._toggleCollapse(notifDelegate.modelData.appName)
}
}
Image {
id: _headerIcon
anchors.left: parent.left
anchors.leftMargin: 10
anchors.top: parent.top
anchors.topMargin: (28 - height) / 2
width: S.Theme.fontSize + 2
height: S.Theme.fontSize + 2
source: {
if (notifDelegate._type !== "header")
return "";
const ic = notifDelegate.modelData.appIcon;
if (!ic)
return "";
return (ic.startsWith("/") || ic.startsWith("file://")) ? ic : Quickshell.iconPath(ic, "dialog-information");
}
visible: status === Image.Ready
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(S.Theme.fontSize + 2, S.Theme.fontSize + 2)
asynchronous: true
}
Text {
id: _chevron
anchors.right: _groupDismissBtn.left
anchors.rightMargin: 8
anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: notifDelegate._type === "header" && notifDelegate.modelData.collapsed ? "\u25B8" : "\u25BE"
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
opacity: _headerHover.hovered ? 1 : 0
}
Text {
anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left
anchors.leftMargin: _headerIcon.visible ? 6 : 10
anchors.right: _chevron.left
anchors.rightMargin: 4
anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: notifDelegate._type === "header" ? (notifDelegate.modelData.appName || "Unknown") : ""
color: S.Theme.base05
font.pixelSize: S.Theme.fontSize - 1
font.family: S.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
}
Text {
id: _groupDismissBtn
anchors.right: parent.right
anchors.rightMargin: 10
anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: "\uF1F8"
color: _groupDismissHover.hovered ? S.Theme.base08 : S.Theme.base04
font.pixelSize: S.Theme.fontSize - 1
font.family: S.Theme.iconFontFamily
opacity: _headerHover.hovered ? 1 : 0
HoverHandler {
id: _groupDismissHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (notifDelegate._type === "header")
root._cascadeGroupDismiss(notifDelegate.modelData.appName);
}
}
}
Repeater {
model: (notifDelegate._type === "header" && notifDelegate.modelData.collapsed) ? notifDelegate.modelData.summaries : []
Text {
required property string modelData
required property int index
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: parent.right
anchors.rightMargin: 10
y: 28 + index * (S.Theme.fontSize + 4)
height: S.Theme.fontSize + 4
verticalAlignment: Text.AlignVCenter
text: modelData
elide: Text.ElideRight
font.pixelSize: S.Theme.fontSize - 2
font.family: S.Theme.fontFamily
color: S.Theme.base04
}
}
}
// ---- Individual notification ----
M.NotifCard {
id: _notifCard
visible: notifDelegate._type === "notif"
anchors.fill: parent
anchors.leftMargin: 8
notif: notifDelegate._notif
showAppName: false
dismissOnAction: false
iconSize: 24
bodyMaxLines: 2
onDismissRequested: notifDelegate.dismiss()
}
SequentialAnimation {
id: _dismissAnim
ParallelAnimation {
NumberAnimation {
target: notifDelegate
property: "x"
to: root.contentWidth
duration: 200
easing.type: Easing.InCubic
}
NumberAnimation {
target: notifDelegate
property: "opacity"
to: 0
duration: 200
easing.type: Easing.InCubic
}
}
NumberAnimation {
target: notifDelegate
property: "_heightScale"
to: 0
duration: 150
easing.type: Easing.OutCubic
}
ScriptAction {
script: {
if (notifDelegate._notif && !notifDelegate._skipDismiss)
S.NotifService.dismiss(notifDelegate._notif.id);
}
}
}
}
}
// Empty state
Text {
visible: S.NotifService.count === 0
width: root.contentWidth
height: 48
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
text: "No notifications"
color: S.Theme.base04
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.fontFamily
}
}

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