Files
dotfiles/quickshell/popouts/NotifPopout.qml
nvrl 32d5683fad feat(quickshell): group the notification log by sender
The log lists one entry per app, ordered by each app's newest
notification. An app with several shows a header with its name, a
count and a clear mark for that app alone, then its newest entry and a
'+ N more' line; clicking either unfolds the rest. Entries under a
header drop their own sender line and keep the urgency dot.
2026-10-04 23:45:57 +02:00

279 lines
9.8 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import "root:/config"
import "root:/components"
import "root:/services"
// Notification history and the do-not-disturb switch.
Popout {
id: root
contentWidth: 400
contentHeight: 380
PopoutSurface {
anchors.fill: parent
tone: Notifs.dnd ? Theme.muted : Theme.primary
PopoutHeader {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
title: "Notifications"
icon: Notifs.icon
accent: Notifs.dnd ? Theme.muted : Theme.primary
subtitle: Notifs.count + " HELD"
}
Row {
id: controls
anchors.top: header.bottom
anchors.topMargin: Theme.padM
anchors.left: parent.left
spacing: Theme.padS
ActionButton {
text: Notifs.dnd ? "Silenced" : "Silence"
icon: Notifs.dnd ? "󰂛" : "󰂚"
accent: Notifs.dnd ? Theme.treatmentOffText : Theme.primary
onClicked: Notifs.toggleDnd()
}
ActionButton {
text: "Clear"
icon: "󰎟"
destructive: true
onClicked: Notifs.clearAll()
}
}
Text {
anchors.centerIn: parent
visible: Notifs.count === 0
text: "NOTHING TO REPORT"
color: Theme.muted
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsBody
font.weight: Font.DemiBold
font.letterSpacing: 3
renderType: Text.NativeRendering
}
// The log, by sender. A sender with one notification is just that
// entry. A sender with several gets a header — its name, how many, a
// clear-all mark — over its newest entry, and opens to show the rest.
ListView {
id: log
anchors.top: controls.bottom
anchors.topMargin: Theme.padM
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
clip: true
spacing: 0
model: Notifs.groups
// Which senders are open, by app name. Survives the list being
// rebuilt when a notification arrives.
property var expanded: ({})
function toggle(app: string) {
const next = Object.assign({}, log.expanded);
next[app] = !next[app];
log.expanded = next;
}
delegate: Column {
id: group
required property var modelData
readonly property string app: group.modelData.app
readonly property var items: group.modelData.items
readonly property bool many: group.items.length > 1
readonly property bool open: log.expanded[group.app] === true
width: ListView.view.width
Item {
id: groupHeader
visible: group.many
width: parent.width
height: group.many ? 30 : 0
Rectangle {
anchors.fill: parent
anchors.margins: Theme.padS / 2
radius: Theme.radiusL
color: headerHover.hovered ? Theme.treatmentHoverFill : "transparent"
Behavior on color {
ColorAnimation { duration: Theme.durFast }
}
}
Row {
id: headerRow
anchors.left: parent.left
anchors.leftMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.padS
Icon {
anchors.verticalCenter: parent.verticalCenter
text: Notifs.appIconFor(group.items[0])
color: Theme.subtext
font.pixelSize: Theme.fsBody
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: group.app.toUpperCase()
color: Theme.text
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
// The count, in a small grey pill.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: countText.implicitWidth + 10
height: countText.implicitHeight + 2
radius: height / 2
color: Theme.surfaceAlt
Text {
id: countText
anchors.centerIn: parent
text: group.items.length
color: Theme.subtext
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
renderType: Text.NativeRendering
}
}
}
Rectangle {
anchors.left: headerRow.right
anchors.leftMargin: Theme.padS
anchors.right: chevron.left
anchors.rightMargin: Theme.padS
anchors.verticalCenter: parent.verticalCenter
height: 1
color: Theme.alpha(Theme.outline, 0.5)
}
Icon {
id: chevron
anchors.right: clearGroup.left
anchors.rightMargin: Theme.padS
anchors.verticalCenter: parent.verticalCenter
text: "󰅀"
rotation: group.open ? 180 : 0
color: headerHover.hovered ? Theme.accent : Theme.muted
font.pixelSize: Theme.fsBody
Behavior on rotation {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutCubic }
}
}
Icon {
id: clearGroup
anchors.right: parent.right
anchors.rightMargin: Theme.padM
anchors.verticalCenter: parent.verticalCenter
text: "󰎟"
opacity: headerHover.hovered ? 1 : 0
color: clearHover.hovered ? Theme.alarm : Theme.muted
font.pixelSize: Theme.fsBody
Behavior on opacity {
NumberAnimation { duration: Theme.durFast }
}
HoverHandler {
id: clearHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: Notifs.dismissApp(group.app)
}
}
HoverHandler {
id: headerHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: log.toggle(group.app)
}
}
Repeater {
model: group.open ? group.items : group.items.slice(0, 1)
NotifCard {
required property var modelData
width: group.width
notif: modelData
showSender: !group.many
onDismissed: Notifs.dismiss(modelData)
}
}
// Folded: say how many more are behind the newest.
Text {
visible: group.many && !group.open
leftPadding: Theme.padM + Theme.marker + Theme.padS
bottomPadding: Theme.padS
text: "+ " + (group.items.length - 1) + " more"
color: moreHover.hovered ? Theme.accent : Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 1
renderType: Text.NativeRendering
HoverHandler {
id: moreHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: log.toggle(group.app)
}
}
}
add: Transition {
NumberAnimation {
property: "opacity"
from: 0; to: 1
duration: Theme.durBase
}
}
remove: Transition {
NumberAnimation {
property: "opacity"
to: 0
duration: Theme.durFast
}
}
displaced: Transition {
NumberAnimation {
properties: "y"
duration: Theme.durBase
easing.type: Easing.OutExpo
}
}
}
}
}