1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-08 22:45:38 -05:00

Initial implementation of grouped notifications

This commit is contained in:
purian23
2025-07-14 10:50:48 -04:00
parent 68bcc2b049
commit cf5c26522b
4 changed files with 654 additions and 216 deletions

View File

@@ -0,0 +1,270 @@
import QtQuick
import Quickshell
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
// Grouped notifications model
property var groupedNotifications: ListModel {}
// Total count of all notifications across all groups
property int totalCount: 0
// Map to track group indices by app name for efficient lookups
property var appGroupMap: ({})
// Configuration
property int maxNotificationsPerGroup: 10
property int maxGroups: 20
Component.onCompleted: {
groupedNotifications = Qt.createQmlObject(`
import QtQuick
ListModel {}
`, root)
}
// Add a new notification to the appropriate group
function addNotification(notificationObj) {
if (!notificationObj || !notificationObj.appName) {
console.warn("Invalid notification object:", notificationObj)
return
}
const appName = notificationObj.appName
let groupIndex = appGroupMap[appName]
if (groupIndex === undefined) {
// Create new group
groupIndex = createNewGroup(appName, notificationObj)
} else {
// Add to existing group
addToExistingGroup(groupIndex, notificationObj)
}
updateTotalCount()
}
// Create a new notification group
function createNewGroup(appName, notificationObj) {
// Check if we need to remove oldest group
if (groupedNotifications.count >= maxGroups) {
removeOldestGroup()
}
const groupIndex = groupedNotifications.count
const notificationsList = Qt.createQmlObject(`
import QtQuick
ListModel {}
`, root)
notificationsList.append(notificationObj)
groupedNotifications.append({
"appName": appName,
"appIcon": notificationObj.appIcon || "",
"notifications": notificationsList,
"totalCount": 1,
"latestNotification": notificationObj,
"expanded": false,
"timestamp": notificationObj.timestamp
})
appGroupMap[appName] = groupIndex
updateGroupMap()
return groupIndex
}
// Add notification to existing group
function addToExistingGroup(groupIndex, notificationObj) {
if (groupIndex >= groupedNotifications.count) {
console.warn("Invalid group index:", groupIndex)
return
}
const group = groupedNotifications.get(groupIndex)
if (!group) return
// Add to front of group (newest first)
group.notifications.insert(0, notificationObj)
// Update group metadata
groupedNotifications.setProperty(groupIndex, "totalCount", group.totalCount + 1)
groupedNotifications.setProperty(groupIndex, "latestNotification", notificationObj)
groupedNotifications.setProperty(groupIndex, "timestamp", notificationObj.timestamp)
// Keep only max notifications per group
while (group.notifications.count > maxNotificationsPerGroup) {
group.notifications.remove(group.notifications.count - 1)
}
// Move group to front (most recent activity)
moveGroupToFront(groupIndex)
}
// Move a group to the front of the list
function moveGroupToFront(groupIndex) {
if (groupIndex === 0) return // Already at front
const group = groupedNotifications.get(groupIndex)
if (!group) return
// Remove from current position
groupedNotifications.remove(groupIndex)
// Insert at front
groupedNotifications.insert(0, group)
// Update group map
updateGroupMap()
}
// Remove the oldest group (least recent activity)
function removeOldestGroup() {
if (groupedNotifications.count === 0) return
const lastIndex = groupedNotifications.count - 1
const group = groupedNotifications.get(lastIndex)
if (group) {
delete appGroupMap[group.appName]
groupedNotifications.remove(lastIndex)
updateGroupMap()
}
}
// Update the app group map after structural changes
function updateGroupMap() {
appGroupMap = {}
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (group) {
appGroupMap[group.appName] = i
}
}
}
// Toggle group expansion state
function toggleGroupExpansion(groupIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (group) {
groupedNotifications.setProperty(groupIndex, "expanded", !group.expanded)
}
}
// Remove a specific notification from a group
function removeNotification(groupIndex, notificationIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (!group || notificationIndex >= group.notifications.count) return
group.notifications.remove(notificationIndex)
// Update group count
const newCount = group.totalCount - 1
groupedNotifications.setProperty(groupIndex, "totalCount", newCount)
// If group is empty, remove it
if (newCount === 0) {
removeGroup(groupIndex)
} else {
// Update latest notification if we removed the latest one
if (notificationIndex === 0 && group.notifications.count > 0) {
const newLatest = group.notifications.get(0)
groupedNotifications.setProperty(groupIndex, "latestNotification", newLatest)
}
}
updateTotalCount()
}
// Remove an entire group
function removeGroup(groupIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (group) {
delete appGroupMap[group.appName]
groupedNotifications.remove(groupIndex)
updateGroupMap()
updateTotalCount()
}
}
// Clear all notifications
function clearAllNotifications() {
groupedNotifications.clear()
appGroupMap = {}
totalCount = 0
}
// Update total count across all groups
function updateTotalCount() {
let count = 0
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (group) {
count += group.totalCount
}
}
totalCount = count
}
// Get notification by ID across all groups
function getNotificationById(notificationId) {
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (!group) continue
for (let j = 0; j < group.notifications.count; j++) {
const notification = group.notifications.get(j)
if (notification && notification.id === notificationId) {
return {
groupIndex: i,
notificationIndex: j,
notification: notification
}
}
}
}
return null
}
// Get group by app name
function getGroupByAppName(appName) {
const groupIndex = appGroupMap[appName]
if (groupIndex !== undefined) {
return {
groupIndex: groupIndex,
group: groupedNotifications.get(groupIndex)
}
}
return null
}
// Get visible notifications for a group (considering expansion state)
function getVisibleNotifications(groupIndex, maxVisible = 3) {
if (groupIndex >= groupedNotifications.count) return []
const group = groupedNotifications.get(groupIndex)
if (!group) return []
if (group.expanded) {
// Show all notifications when expanded
return group.notifications
} else {
// Show only the latest notification(s) when collapsed
const visibleCount = Math.min(maxVisible, group.notifications.count)
const visible = []
for (let i = 0; i < visibleCount; i++) {
visible.push(group.notifications.get(i))
}
return visible
}
}
}

View File

@@ -15,3 +15,4 @@ singleton NiriWorkspaceService 1.0 NiriWorkspaceService.qml
singleton CalendarService 1.0 CalendarService.qml singleton CalendarService 1.0 CalendarService.qml
singleton UserInfoService 1.0 UserInfoService.qml singleton UserInfoService 1.0 UserInfoService.qml
singleton FocusedWindowService 1.0 FocusedWindowService.qml singleton FocusedWindowService 1.0 FocusedWindowService.qml
singleton NotificationGroupingService 1.0 NotificationGroupingService.qml

View File

@@ -5,6 +5,7 @@ import Quickshell
import Quickshell.Widgets import Quickshell.Widgets
import Quickshell.Wayland import Quickshell.Wayland
import "../Common" import "../Common"
import "../Services"
PanelWindow { PanelWindow {
id: notificationHistoryPopup id: notificationHistoryPopup
@@ -89,7 +90,7 @@ PanelWindow {
height: 28 height: 28
radius: Theme.cornerRadius radius: Theme.cornerRadius
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: notificationHistory.count > 0 visible: NotificationGroupingService.totalCount > 0
color: clearArea.containsMouse ? color: clearArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
@@ -128,6 +129,7 @@ PanelWindow {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
NotificationGroupingService.clearAllNotifications()
notificationHistory.clear() notificationHistory.clear()
} }
} }
@@ -149,24 +151,225 @@ PanelWindow {
} }
} }
// Notification List // Grouped Notification List
ScrollView { ScrollView {
width: parent.width width: parent.width
height: parent.height - 120 height: parent.height - 120
clip: true clip: true
ListView { ListView {
id: notificationListView id: groupedNotificationListView
model: notificationHistory model: NotificationGroupingService.groupedNotifications
spacing: Theme.spacingM
delegate: Column {
width: groupedNotificationListView.width
spacing: Theme.spacingXS
property var groupData: model
property bool isExpanded: model.expanded || false
// Group Header
Rectangle {
width: parent.width
height: 56
radius: Theme.cornerRadius
color: groupHeaderArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
Row {
anchors.fill: parent
anchors.margins: Theme.spacingM
spacing: Theme.spacingM
// App Icon
Rectangle {
width: 32
height: 32
radius: width / 2
color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter
// Material icon fallback
Loader {
active: !model.appIcon || model.appIcon === ""
anchors.fill: parent
sourceComponent: Text {
anchors.centerIn: parent
text: "apps"
font.family: Theme.iconFont
font.pixelSize: 16
color: Theme.primaryText
}
}
// App icon
Loader {
active: model.appIcon && model.appIcon !== ""
anchors.centerIn: parent
sourceComponent: IconImage {
width: 24
height: 24
asynchronous: true
source: {
if (!model.appIcon) return ""
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
}
// App Name and Summary
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 100
spacing: 2
Row {
width: parent.width
spacing: Theme.spacingS spacing: Theme.spacingS
Text {
text: model.appName || "App"
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceText
font.weight: Font.Medium
}
// Notification count badge
Rectangle {
width: Math.max(countText.width + 8, 20)
height: 20
radius: 10
color: Theme.primary
visible: model.totalCount > 1
anchors.verticalCenter: parent.verticalCenter
Text {
id: countText
anchors.centerIn: parent
text: model.totalCount.toString()
font.pixelSize: Theme.fontSizeSmall
color: Theme.primaryText
font.weight: Font.Medium
}
}
}
Text {
text: model.latestNotification ?
(model.latestNotification.summary || model.latestNotification.body || "") : ""
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
width: parent.width
elide: Text.ElideRight
visible: text.length > 0
}
}
// Expand/Collapse Icon
Rectangle {
width: 32
height: 32
radius: 16
anchors.verticalCenter: parent.verticalCenter
color: groupHeaderArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
"transparent"
Text {
anchors.centerIn: parent
text: isExpanded ? "expand_less" : "expand_more"
font.family: Theme.iconFont
font.pixelSize: 20
color: groupHeaderArea.containsMouse ? Theme.primary : Theme.surfaceText
Behavior on rotation {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
}
// Close group button
Rectangle {
width: 24
height: 24
radius: 12
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 6
color: closeGroupArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
"transparent"
Text {
anchors.centerIn: parent
text: "close"
font.family: Theme.iconFont
font.pixelSize: 14
color: closeGroupArea.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: closeGroupArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
NotificationGroupingService.removeGroup(index)
}
}
}
MouseArea {
id: groupHeaderArea
anchors.fill: parent
anchors.rightMargin: 32
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
NotificationGroupingService.toggleGroupExpansion(index)
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
// Expanded Notifications List
Loader {
width: parent.width
active: isExpanded
sourceComponent: Column {
width: parent.width
spacing: Theme.spacingXS
Repeater {
model: groupData.notifications
delegate: Rectangle { delegate: Rectangle {
width: notificationListView.width width: parent.width
height: 80 height: 80
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: notifArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) color: notifArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) :
Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)
// Close button for individual notification // Individual notification close button
Rectangle { Rectangle {
width: 24 width: 24
height: 24 height: 24
@@ -174,7 +377,9 @@ PanelWindow {
anchors.right: parent.right anchors.right: parent.right
anchors.top: parent.top anchors.top: parent.top
anchors.margins: 8 anchors.margins: 8
color: closeNotifArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" color: closeNotifArea.containsMouse ?
Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) :
"transparent"
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
@@ -190,14 +395,9 @@ PanelWindow {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
notificationHistory.remove(index) // Use the parent ListView's index to get the group index
} let groupIndex = parent.parent.parent.parent.parent.index
} NotificationGroupingService.removeNotification(groupIndex, model.index)
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
} }
} }
} }
@@ -205,18 +405,18 @@ PanelWindow {
Row { Row {
anchors.fill: parent anchors.fill: parent
anchors.margins: Theme.spacingM anchors.margins: Theme.spacingM
anchors.rightMargin: 36 // Don't overlap with close button anchors.rightMargin: 36
spacing: Theme.spacingM spacing: Theme.spacingM
// Notification icon based on EXAMPLE NotificationAppIcon pattern // Notification icon
Rectangle { Rectangle {
width: 48 width: 48
height: 48 height: 48
radius: width / 2 // Fully rounded like EXAMPLE radius: width / 2
color: Theme.primaryContainer color: Theme.primaryContainer
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
// Material icon fallback (when no app icon) // Material icon fallback
Loader { Loader {
active: !model.appIcon || model.appIcon === "" active: !model.appIcon || model.appIcon === ""
anchors.fill: parent anchors.fill: parent
@@ -226,8 +426,6 @@ PanelWindow {
font.family: Theme.iconFont font.family: Theme.iconFont
font.pixelSize: 20 font.pixelSize: 20
color: Theme.primaryText color: Theme.primaryText
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
} }
} }
@@ -241,17 +439,15 @@ PanelWindow {
asynchronous: true asynchronous: true
source: { source: {
if (!model.appIcon) return "" if (!model.appIcon) return ""
// Handle file:// URLs directly
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon return model.appIcon
} }
// Otherwise treat as icon name
return Quickshell.iconPath(model.appIcon, "image-missing") return Quickshell.iconPath(model.appIcon, "image-missing")
} }
} }
} }
// Notification image (like Discord user avatar) - PRIORITY // Notification image (priority)
Loader { Loader {
active: model.image && model.image !== "" active: model.image && model.image !== ""
anchors.fill: parent anchors.fill: parent
@@ -259,17 +455,14 @@ PanelWindow {
anchors.fill: parent anchors.fill: parent
Image { Image {
id: historyNotifImage id: notifImage
anchors.fill: parent anchors.fill: parent
source: model.image || "" source: model.image || ""
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
cache: true // Enable caching for history cache: true
antialiasing: true antialiasing: true
asynchronous: true asynchronous: true
smooth: true smooth: true
// Use the parent size for optimization
sourceSize.width: parent.width sourceSize.width: parent.width
sourceSize.height: parent.height sourceSize.height: parent.height
@@ -279,40 +472,14 @@ PanelWindow {
maskSource: Rectangle { maskSource: Rectangle {
width: 48 width: 48
height: 48 height: 48
radius: 24 // Fully rounded radius: 24
}
}
onStatusChanged: {
if (status === Image.Error) {
console.warn("Failed to load notification history image:", source)
} else if (status === Image.Ready) {
console.log("Notification history image loaded:", source, "size:", sourceSize.width + "x" + sourceSize.height)
} }
} }
} }
// Fallback to app icon when primary image fails // Small app icon overlay
Loader { Loader {
active: model.appIcon && model.appIcon !== "" && historyNotifImage.status === Image.Error active: model.appIcon && model.appIcon !== "" && notifImage.status === Image.Ready
anchors.centerIn: parent
sourceComponent: IconImage {
width: 32
height: 32
asynchronous: true
source: {
if (!model.appIcon) return ""
if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) {
return model.appIcon
}
return Quickshell.iconPath(model.appIcon, "image-missing")
}
}
}
// Small app icon overlay when showing notification image
Loader {
active: model.appIcon && model.appIcon !== "" && historyNotifImage.status === Image.Ready
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.right: parent.right anchors.right: parent.right
sourceComponent: IconImage { sourceComponent: IconImage {
@@ -332,19 +499,12 @@ PanelWindow {
} }
} }
// Content // Notification content
Column { Column {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: parent.width - 80 width: parent.width - 80
spacing: Theme.spacingXS spacing: Theme.spacingXS
Text {
text: model.appName || "App"
font.pixelSize: Theme.fontSizeSmall
color: Theme.primary
font.weight: Font.Medium
}
Text { Text {
text: model.summary || "" text: model.summary || ""
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
@@ -371,17 +531,17 @@ PanelWindow {
MouseArea { MouseArea {
id: notifArea id: notifArea
anchors.fill: parent anchors.fill: parent
anchors.rightMargin: 32 // Don't overlap with close button area anchors.rightMargin: 32
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
// Try to handle notification click if it has actions
if (model && root.handleNotificationClick) { if (model && root.handleNotificationClick) {
root.handleNotificationClick(model) root.handleNotificationClick(model)
} }
// Remove from history after handling // Use the parent ListView's index to get the group index
notificationHistory.remove(index) let groupIndex = parent.parent.parent.parent.parent.index
NotificationGroupingService.removeNotification(groupIndex, model.index)
} }
} }
@@ -393,11 +553,15 @@ PanelWindow {
} }
} }
} }
}
}
}
}
// Empty state - properly centered // Empty state
Item { Item {
anchors.fill: parent anchors.fill: parent
visible: notificationHistory.count === 0 visible: NotificationGroupingService.totalCount === 0
Column { Column {
anchors.centerIn: parent anchors.centerIn: parent
@@ -424,7 +588,7 @@ PanelWindow {
Text { Text {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
text: "Notifications will appear here" text: "Notifications will appear here grouped by app"
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4) color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4)
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter

View File

@@ -245,10 +245,13 @@ ShellRoot {
"notification": notification // Keep reference for action handling "notification": notification // Keep reference for action handling
} }
// Add to history (prepend to show newest first) // Add to grouped notifications
NotificationGroupingService.addNotification(notifObj)
// Also add to legacy flat history for backwards compatibility
notificationHistory.insert(0, notifObj) notificationHistory.insert(0, notifObj)
// Keep only last 50 notifications // Keep only last 50 notifications in flat history
while (notificationHistory.count > 50) { while (notificationHistory.count > 50) {
notificationHistory.remove(notificationHistory.count - 1) notificationHistory.remove(notificationHistory.count - 1)
} }
@@ -303,7 +306,7 @@ ShellRoot {
bluetoothAvailable: root.bluetoothAvailable bluetoothAvailable: root.bluetoothAvailable
bluetoothEnabled: root.bluetoothEnabled bluetoothEnabled: root.bluetoothEnabled
shellRoot: root shellRoot: root
notificationCount: notificationHistory.count notificationCount: NotificationGroupingService.totalCount
processDropdown: processListDropdown processDropdown: processListDropdown
// Connect tray menu properties // Connect tray menu properties