diff --git a/quickshell/README.md b/quickshell/README.md index 540cf09..fca937e 100644 --- a/quickshell/README.md +++ b/quickshell/README.md @@ -94,6 +94,15 @@ corner instead, attached to the bar and the rail at once (`dock` is `top`, `right`, `topRight` or `bottomRight`). Without docking, a panel near the rail left a thin strip of opening between itself and the rail. +## Notifications + +The shell is the notification server (`services/Notifs.qml`). New ones arrive +as toasts in a corner drawer (`overlays/Toasts.qml`); the bell on the rail +opens the log (`popouts/NotifPopout.qml`), which is grouped by sender. A +sender with one notification is just that entry. A sender with several gets a +header — its name, a count, a mark to clear only that sender — over its +newest entry and a `+ 2 more` line; a click on either opens the rest. + ## Dashboard `dashboard/Dashboard.qml` is a drawer that slides down out of the underside of diff --git a/quickshell/components/NotifCard.qml b/quickshell/components/NotifCard.qml index f92ad8c..541a11e 100644 --- a/quickshell/components/NotifCard.qml +++ b/quickshell/components/NotifCard.qml @@ -22,6 +22,9 @@ Item { required property var notif property bool showActions: true + // Off inside a group in the log, whose header already names the sender: + // the entry keeps only its urgency dot, the hairline and the close mark. + property bool showSender: true readonly property bool critical: root.notif?.urgency === NotificationUrgency.Critical readonly property color tone: root.notif @@ -79,7 +82,7 @@ Item { Icon { id: glyph - visible: text !== "" + visible: root.showSender && text !== "" anchors.left: marker.right anchors.leftMargin: Theme.padS anchors.verticalCenter: parent.verticalCenter @@ -91,8 +94,10 @@ Item { Text { id: sender anchors.left: glyph.visible ? glyph.right : marker.right - anchors.leftMargin: Theme.padS + anchors.leftMargin: root.showSender ? Theme.padS : 0 anchors.verticalCenter: parent.verticalCenter + width: root.showSender ? implicitWidth : 0 + visible: root.showSender text: (root.notif?.appName || "System").toUpperCase() color: root.critical ? Theme.alarm : Theme.muted font.family: Theme.fontMono diff --git a/quickshell/popouts/NotifPopout.qml b/quickshell/popouts/NotifPopout.qml index bcc12a4..459f1d8 100644 --- a/quickshell/popouts/NotifPopout.qml +++ b/quickshell/popouts/NotifPopout.qml @@ -61,22 +61,193 @@ Popout { 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 - // Entries carry their own header hairline, so they sit flush. spacing: 0 - model: Notifs.history + model: Notifs.groups - delegate: NotifCard { + // 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 - notif: modelData - onDismissed: Notifs.dismiss(modelData) + + 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 { diff --git a/quickshell/services/Notifs.qml b/quickshell/services/Notifs.qml index 44b6645..edc2f4c 100644 --- a/quickshell/services/Notifs.qml +++ b/quickshell/services/Notifs.qml @@ -17,6 +17,19 @@ Singleton { property int maxHistory: 60 readonly property int count: history.length + + // The history by sender, for the log: one entry per app, newest first + // inside each, the apps ordered by their newest notification. + readonly property var groups: { + const byApp = {}; + const order = []; + for (const n of root.history) { + const app = n.appName || "System"; + if (!(app in byApp)) { byApp[app] = []; order.push(app); } + byApp[app].push(n); + } + return order.map(app => ({ app: app, items: byApp[app] })); + } readonly property bool hasUnread: unread > 0 property int unread: 0 @@ -57,6 +70,12 @@ Singleton { notif.dismiss(); } + function dismissApp(app: string) { + const gone = root.history.filter(n => (n.appName || "System") === app); + root.history = root.history.filter(n => (n.appName || "System") !== app); + for (const n of gone) n.dismiss(); + } + function clearAll() { const all = root.history.slice(); root.history = [];