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.
This commit is contained in:
1 parent
ec76f8dec3
commit
32d5683fad
4 files changed
+211
-7
No files matched your search
@@ -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
|
`right`, `topRight` or `bottomRight`). Without docking, a panel near the rail
|
||||||
left a thin strip of opening between itself and 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/Dashboard.qml` is a drawer that slides down out of the underside of
|
`dashboard/Dashboard.qml` is a drawer that slides down out of the underside of
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ Item {
|
|||||||
|
|
||||||
required property var notif
|
required property var notif
|
||||||
property bool showActions: true
|
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 bool critical: root.notif?.urgency === NotificationUrgency.Critical
|
||||||
readonly property color tone: root.notif
|
readonly property color tone: root.notif
|
||||||
@@ -79,7 +82,7 @@ Item {
|
|||||||
|
|
||||||
Icon {
|
Icon {
|
||||||
id: glyph
|
id: glyph
|
||||||
visible: text !== ""
|
visible: root.showSender && text !== ""
|
||||||
anchors.left: marker.right
|
anchors.left: marker.right
|
||||||
anchors.leftMargin: Theme.padS
|
anchors.leftMargin: Theme.padS
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
@@ -91,8 +94,10 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
id: sender
|
id: sender
|
||||||
anchors.left: glyph.visible ? glyph.right : marker.right
|
anchors.left: glyph.visible ? glyph.right : marker.right
|
||||||
anchors.leftMargin: Theme.padS
|
anchors.leftMargin: root.showSender ? Theme.padS : 0
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: root.showSender ? implicitWidth : 0
|
||||||
|
visible: root.showSender
|
||||||
text: (root.notif?.appName || "System").toUpperCase()
|
text: (root.notif?.appName || "System").toUpperCase()
|
||||||
color: root.critical ? Theme.alarm : Theme.muted
|
color: root.critical ? Theme.alarm : Theme.muted
|
||||||
font.family: Theme.fontMono
|
font.family: Theme.fontMono
|
||||||
|
|||||||
@@ -61,22 +61,193 @@ Popout {
|
|||||||
renderType: Text.NativeRendering
|
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 {
|
ListView {
|
||||||
|
id: log
|
||||||
anchors.top: controls.bottom
|
anchors.top: controls.bottom
|
||||||
anchors.topMargin: Theme.padM
|
anchors.topMargin: Theme.padM
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
clip: true
|
clip: true
|
||||||
// Entries carry their own header hairline, so they sit flush.
|
|
||||||
spacing: 0
|
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
|
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
|
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 {
|
add: Transition {
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ Singleton {
|
|||||||
property int maxHistory: 60
|
property int maxHistory: 60
|
||||||
|
|
||||||
readonly property int count: history.length
|
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
|
readonly property bool hasUnread: unread > 0
|
||||||
property int unread: 0
|
property int unread: 0
|
||||||
|
|
||||||
@@ -57,6 +70,12 @@ Singleton {
|
|||||||
notif.dismiss();
|
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() {
|
function clearAll() {
|
||||||
const all = root.history.slice();
|
const all = root.history.slice();
|
||||||
root.history = [];
|
root.history = [];
|
||||||
|
|||||||
Reference in new issue
Block a user