NotificationHistory Updates

Add read/unread bell icon
Edit style of the Panel
Small fixes
This commit is contained in:
ly-sec 2025-07-17 16:56:51 +02:00
parent cf26fe52d9
commit dbb5a9160c
6 changed files with 416 additions and 409 deletions

View file

@ -9,11 +9,11 @@ import qs.Settings
import qs.Services import qs.Services
import qs.Components import qs.Components
import qs.Widgets import qs.Widgets
import qs.Widgets.Notification
import qs.Widgets.Sidebar import qs.Widgets.Sidebar
import qs.Widgets.Sidebar.Panel import qs.Widgets.Sidebar.Panel
import qs.Helpers import qs.Helpers
import QtQuick.Controls import QtQuick.Controls
import qs.Widgets.Notification
Scope { Scope {
id: rootScope id: rootScope
@ -83,40 +83,9 @@ Scope {
anchors.rightMargin: 18 anchors.rightMargin: 18
spacing: 12 spacing: 12
Item {
id: notificationBellButton
width: 22
height: 22
anchors.verticalCenter: parent.verticalCenter
z: 1
Rectangle {
id: bellBg
width: 22
height: 22
radius: 11
color: mouseAreaBell.containsMouse ? Theme.accentPrimary : "transparent"
visible: mouseAreaBell.containsMouse
anchors.centerIn: parent
}
Text {
anchors.centerIn: parent
text: "notifications"
font.family: mouseAreaBell.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 16
color: mouseAreaBell.containsMouse ? Theme.backgroundPrimary : Theme.textPrimary
}
MouseArea {
id: mouseAreaBell
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: notificationHistoryWin.visible = !notificationHistoryWin.visible
}
}
NotificationHistory { NotificationHistory {
id: notificationHistoryWin id: notificationHistoryWin
anchors.verticalCenter: parent.verticalCenter
} }
Brightness { Brightness {
@ -158,28 +127,25 @@ Scope {
} }
} }
Background {} Background {}
Overview {} Overview {}
} }
PanelWindow { PanelWindow {
id: topLeftPanel id: topCornerPanel
anchors.top: true anchors.top: true
anchors.left: true anchors.left: true
anchors.right: true
color: "transparent" color: "transparent"
screen: modelData screen: modelData
margins.top: 36 margins.top: 36
WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: "swww-daemon"
implicitHeight: 24 implicitHeight: 24
Corners { Corners {
id: topLeftCorner id: topleftCorner
position: "bottomleft" position: "bottomleft"
size: 1.3 size: 1.3
fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222" fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222"
@ -187,25 +153,9 @@ Scope {
offsetY: 0 offsetY: 0
anchors.top: parent.top anchors.top: parent.top
} }
}
PanelWindow {
id: topRightPanel
anchors.top: true
anchors.right: true
color: "transparent"
screen: modelData
margins.top: 36
WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: "swww-daemon"
implicitHeight: 24
Corners { Corners {
id: topRightCorner id: toprightCorner
position: "bottomright" position: "bottomright"
size: 1.3 size: 1.3
fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222" fillColor: (Theme.backgroundPrimary !== undefined && Theme.backgroundPrimary !== null) ? Theme.backgroundPrimary : "#222"
@ -223,9 +173,6 @@ Scope {
screen: modelData screen: modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: "swww-daemon"
implicitHeight: 24 implicitHeight: 24
@ -241,16 +188,13 @@ Scope {
} }
PanelWindow { PanelWindow {
id: bottomRightPanel id: bottomRightCornerPanel
anchors.bottom: true anchors.bottom: true
anchors.right: true anchors.right: true
color: "transparent" color: "transparent"
screen: modelData screen: modelData
WlrLayershell.exclusionMode: ExclusionMode.Ignore WlrLayershell.exclusionMode: ExclusionMode.Ignore
visible: true visible: true
WlrLayershell.layer: WlrLayer.Background
aboveWindows: false
WlrLayershell.namespace: "swww-daemon"
implicitHeight: 24 implicitHeight: 24
@ -264,6 +208,10 @@ Scope {
anchors.top: parent.top anchors.top: parent.top
} }
} }
Loader {
id: tabViewerLoader
}
} }
} }

View file

@ -3,6 +3,7 @@ import Quickshell.Io
IpcHandler { IpcHandler {
property var appLauncherPanel property var appLauncherPanel
property var lockScreen property var lockScreen
property var tabViewer
target: "globalIPC" target: "globalIPC"

View file

@ -89,7 +89,7 @@ Singleton {
id: i, id: i,
idx: ws.id, idx: ws.id,
name: ws.name || "", name: ws.name || "",
output: ws.monitor?.name || "", output: ws.monitor.name || "",
isActive: ws.active === true, isActive: ws.active === true,
isFocused: ws.focused === true, isFocused: ws.focused === true,
isUrgent: ws.urgent === true isUrgent: ws.urgent === true

View file

@ -1,28 +1,28 @@
{ {
"backgroundPrimary": "#111211", "backgroundPrimary": "#0E0F10",
"backgroundSecondary": "#1D1E1D", "backgroundSecondary": "#1A1B1C",
"backgroundTertiary": "#292A29", "backgroundTertiary": "#262728",
"surface": "#242524", "surface": "#212223",
"surfaceVariant": "#353635", "surfaceVariant": "#323334",
"textPrimary": "#F4E9E3", "textPrimary": "#F0F1E0",
"textSecondary": "#DCD2CC", "textSecondary": "#D8D9CA",
"textDisabled": "#928C88", "textDisabled": "#909186",
"accentPrimary": "#7D8079", "accentPrimary": "#A3A485",
"accentSecondary": "#979994", "accentSecondary": "#B5B69D",
"accentTertiary": "#646661", "accentTertiary": "#82836A",
"error": "#939849", "error": "#A5A9ED",
"warning": "#ABAF72", "warning": "#B9BCF1",
"highlight": "#B1B3AF", "highlight": "#C8C8B6",
"rippleEffect": "#8A8D86", "rippleEffect": "#ACAD91",
"onAccent": "#111211", "onAccent": "#0E0F10",
"outline": "#585958", "outline": "#565758",
"shadow": "#111211", "shadow": "#0E0F10",
"overlay": "#111211" "overlay": "#0E0F10"
} }

View file

@ -1,15 +1,48 @@
import QtQuick import QtQuick 2.15
import QtQuick.Controls import QtQuick.Controls 2.15
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import qs.Components import qs.Components
import qs.Settings import qs.Settings
import QtQuick.Layouts import QtQuick.Layouts 1.15
Item {
id: root
property string configDir: Quickshell.configDir
property string historyFilePath: configDir + "/notification_history.json"
property bool hasUnread: notificationHistoryWin.hasUnread && !notificationHistoryWin.visible
function addToHistory(notification) { notificationHistoryWin.addToHistory(notification) }
width: 22; height: 22
// Bell icon/button
Item {
id: bell
width: 22; height: 22
Text {
anchors.centerIn: parent
text: root.hasUnread ? "notifications_unread" : "notifications"
font.family: mouseAreaBell.containsMouse ? "Material Symbols Rounded" : "Material Symbols Outlined"
font.pixelSize: 16
font.weight: root.hasUnread ? Font.Bold : Font.Normal
color: mouseAreaBell.containsMouse ? Theme.accentPrimary : (root.hasUnread ? Theme.accentPrimary : Theme.textDisabled)
}
MouseArea {
id: mouseAreaBell
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: notificationHistoryWin.visible = !notificationHistoryWin.visible
}
}
// The popup window
PanelWindow { PanelWindow {
id: notificationHistoryWin id: notificationHistoryWin
width: 400 width: 400
height: 500 property int maxPopupHeight: 500
property int minPopupHeight: 230
property int contentHeight: headerRow.height + historyList.contentHeight + 56
height: Math.max(Math.min(contentHeight, maxPopupHeight), minPopupHeight)
color: "transparent" color: "transparent"
visible: false visible: false
screen: Quickshell.primaryScreen screen: Quickshell.primaryScreen
@ -20,48 +53,47 @@ PanelWindow {
margins.right: 4 margins.right: 4
property int maxHistory: 100 property int maxHistory: 100
property string configDir: Quickshell.configDir property bool hasUnread: false
property string historyFilePath: configDir + "/notification_history.json" signal unreadChanged(bool hasUnread)
ListModel { ListModel {
id: historyModel // Holds notification objects id: historyModel
} }
FileView { FileView {
id: historyFileView id: historyFileView
path: historyFilePath path: root.historyFilePath
blockLoading: true blockLoading: true
printErrors: true printErrors: true
watchChanges: true watchChanges: true
JsonAdapter { JsonAdapter {
id: historyAdapter id: historyAdapter
property var notifications: [] // Array of notification objects property var notifications: []
}
onFileChanged: {
reload() // Reload if file changes on disk
}
onLoaded: {
loadHistory() // Populate model after loading
} }
onFileChanged: historyFileView.reload()
onLoaded: notificationHistoryWin.loadHistory()
onLoadFailed: function(error) { onLoadFailed: function(error) {
console.error("Failed to load history file:", error)
if (error.includes("No such file")) { if (error.includes("No such file")) {
historyAdapter.notifications = [] // Create new file if missing historyAdapter.notifications = []
writeAdapter() writeAdapter()
} }
} }
Component.onCompleted: if (path) reload()
onSaved: {}
onSaveFailed: function(error) {
console.error("Failed to save history:", error)
} }
Component.onCompleted: { function updateHasUnread() {
if (path) reload() var unread = false;
for (let i = 0; i < historyModel.count; ++i) {
if (historyModel.get(i).read === false) {
unread = true;
break;
}
}
if (hasUnread !== unread) {
hasUnread = unread;
unreadChanged(hasUnread);
} }
} }
@ -71,10 +103,15 @@ PanelWindow {
const notifications = historyAdapter.notifications const notifications = historyAdapter.notifications
const count = Math.min(notifications.length, maxHistory) const count = Math.min(notifications.length, maxHistory)
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
if (typeof notifications[i] === 'object' && notifications[i] !== null) { let n = notifications[i]
historyModel.append(notifications[i]) if (typeof n === 'object' && n !== null) {
if (n.read === undefined) n.read = false;
// Mark as read if window is open
if (visible) n.read = true;
historyModel.append(n)
} }
} }
updateHasUnread();
} }
} }
@ -89,7 +126,8 @@ PanelWindow {
appName: obj.appName, appName: obj.appName,
summary: obj.summary, summary: obj.summary,
body: obj.body, body: obj.body,
timestamp: obj.timestamp timestamp: obj.timestamp,
read: obj.read === undefined ? false : obj.read
}) })
} }
} }
@ -97,18 +135,26 @@ PanelWindow {
Qt.callLater(function() { Qt.callLater(function() {
historyFileView.writeAdapter() historyFileView.writeAdapter()
}) })
updateHasUnread();
} }
function addToHistory(notification) { function addToHistory(notification) {
if (!notification.id) notification.id = Date.now() if (!notification.id) notification.id = Date.now()
if (!notification.timestamp) notification.timestamp = new Date().toISOString() if (!notification.timestamp) notification.timestamp = new Date().toISOString()
// Mark as read if window is open
notification.read = visible
// Remove duplicate by id
for (let i = 0; i < historyModel.count; ++i) { for (let i = 0; i < historyModel.count; ++i) {
if (historyModel.get(i).id === notification.id) { if (historyModel.get(i).id === notification.id) {
historyModel.remove(i) historyModel.remove(i)
break break
} }
} }
historyModel.insert(0, notification) historyModel.insert(0, notification)
if (historyModel.count > maxHistory) historyModel.remove(maxHistory) if (historyModel.count > maxHistory) historyModel.remove(maxHistory)
saveHistory() saveHistory()
} }
@ -130,6 +176,20 @@ PanelWindow {
return `${y}-${m}-${d} ${h}:${min}`; return `${y}-${m}-${d} ${h}:${min}`;
} }
onVisibleChanged: {
if (visible) {
// Mark all as read when popup is opened
let changed = false;
for (let i = 0; i < historyModel.count; ++i) {
if (historyModel.get(i).read === false) {
historyModel.setProperty(i, 'read', true);
changed = true;
}
}
if (changed) saveHistory();
}
}
Rectangle { Rectangle {
width: notificationHistoryWin.width width: notificationHistoryWin.width
height: notificationHistoryWin.height height: notificationHistoryWin.height
@ -143,9 +203,10 @@ PanelWindow {
spacing: 8 spacing: 8
RowLayout { RowLayout {
id: headerRow
spacing: 4 spacing: 4
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 16 anchors.topMargin: 4
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
anchors.leftMargin: 16 anchors.leftMargin: 16
@ -162,7 +223,7 @@ PanelWindow {
id: clearAllButton id: clearAllButton
width: 90 width: 90
height: 32 height: 32
radius: 20 radius: 16
color: clearAllMouseArea.containsMouse ? Theme.accentPrimary : Theme.surfaceVariant color: clearAllMouseArea.containsMouse ? Theme.accentPrimary : Theme.surfaceVariant
border.color: Theme.accentPrimary border.color: Theme.accentPrimary
border.width: 1 border.width: 1
@ -213,10 +274,8 @@ PanelWindow {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: Theme.backgroundPrimary color: Theme.surface
radius: 20 radius: 20
border.width: 1
border.color: Theme.surfaceVariant
z: 0 z: 0
} }
Rectangle { Rectangle {
@ -226,12 +285,19 @@ PanelWindow {
anchors.bottomMargin: 12 anchors.bottomMargin: 12
color: "transparent" color: "transparent"
clip: true clip: true
Column {
anchors.fill: parent
spacing: 0
Item { id: topSpacer; height: (parent.height - historyList.height) / 2 }
ListView { ListView {
id: historyList id: historyList
anchors.fill: parent width: parent.width
height: Math.min(contentHeight, parent.height)
spacing: 12 spacing: 12
model: historyModel model: historyModel.count > 0 ? historyModel : placeholderModel
clip: true
delegate: Item { delegate: Item {
width: parent.width
height: notificationCard.implicitHeight + 12 height: notificationCard.implicitHeight + 12
Rectangle { Rectangle {
id: notificationCard id: notificationCard
@ -239,8 +305,6 @@ PanelWindow {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
color: Theme.backgroundPrimary color: Theme.backgroundPrimary
radius: 16 radius: 16
border.color: Theme.outline
border.width: 1
anchors.top: parent.top anchors.top: parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.margins: 0 anchors.margins: 0
@ -253,9 +317,6 @@ PanelWindow {
RowLayout { RowLayout {
id: headerRow id: headerRow
spacing: 8 spacing: 8
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: 0
Rectangle { Rectangle {
id: iconBackground id: iconBackground
width: 28 width: 28
@ -279,7 +340,7 @@ PanelWindow {
spacing: 0 spacing: 0
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
Text { Text {
text: model.appName || "Unknown App" text: model.appName || "No Notifications"
font.bold: true font.bold: true
color: Theme.textPrimary color: Theme.textPrimary
font.family: Theme.fontFamily font.family: Theme.fontFamily
@ -287,49 +348,18 @@ PanelWindow {
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
Text { Text {
text: formatTimestamp(model.timestamp) visible: !model.isPlaceholder
color: Theme.textSecondary text: model.timestamp ? notificationHistoryWin.formatTimestamp(model.timestamp) : ""
color: Theme.textDisabled
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeCaption font.pixelSize: Theme.fontSizeCaption
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
} }
} }
Item { Layout.fillWidth: true } Item { Layout.fillWidth: true }
Rectangle {
id: deleteButton
width: 24
height: 24
radius: 12
color: deleteMouseArea.containsMouse ? Theme.accentPrimary : Theme.surfaceVariant
border.color: Theme.accentPrimary
border.width: 1
Layout.alignment: Qt.AlignVCenter
z: 2
Row {
anchors.centerIn: parent
spacing: 0
Text {
text: "close"
font.family: "Material Symbols Outlined"
font.pixelSize: 16
color: deleteMouseArea.containsMouse ? Theme.onAccent : Theme.error
verticalAlignment: Text.AlignVCenter
}
}
MouseArea {
id: deleteMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
historyModel.remove(index)
saveHistory()
}
}
}
} }
Text { Text {
text: model.summary || "" text: model.summary || (model.isPlaceholder ? "You're all caught up!" : "")
color: Theme.textSecondary color: Theme.textSecondary
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeBody font.pixelSize: Theme.fontSizeBody
@ -337,7 +367,7 @@ PanelWindow {
wrapMode: Text.Wrap wrapMode: Text.Wrap
} }
Text { Text {
text: model.body || "" text: model.body || (model.isPlaceholder ? "No notifications to show." : "")
color: Theme.textDisabled color: Theme.textDisabled
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeBody font.pixelSize: Theme.fontSizeBody
@ -350,8 +380,20 @@ PanelWindow {
} }
} }
} }
}
Rectangle { width: 1; height: 24; color: "transparent" } Rectangle { width: 1; height: 24; color: "transparent" }
ListModel {
id: placeholderModel
ListElement {
appName: ""
summary: ""
body: ""
isPlaceholder: true
}
}
}
} }
} }
} }

View file

@ -15,6 +15,7 @@ Scope {
id: root id: root
property alias appLauncherPanel: appLauncherPanel property alias appLauncherPanel: appLauncherPanel
property var notificationHistoryWin: notificationHistoryWin
function updateVolume(vol) { function updateVolume(vol) {
volume = vol; volume = vol;
@ -30,6 +31,7 @@ Scope {
Bar { Bar {
id: bar id: bar
shell: root shell: root
property var notificationHistoryWin: notificationHistoryWin
} }
Applauncher { Applauncher {
@ -47,6 +49,15 @@ Scope {
console.log("Notification received:", notification.appName); console.log("Notification received:", notification.appName);
notification.tracked = true; notification.tracked = true;
notificationPopup.addNotification(notification); notificationPopup.addNotification(notification);
if (notificationHistoryWin) {
notificationHistoryWin.addToHistory({
id: notification.id,
appName: notification.appName || "Notification",
summary: notification.summary || "",
body: notification.body || "",
timestamp: Date.now()
});
}
} }
} }
@ -55,6 +66,11 @@ Scope {
barVisible: bar.visible barVisible: bar.visible
} }
// Notification History Window
NotificationHistory {
id: notificationHistoryWin
}
property var defaultAudioSink: Pipewire.defaultAudioSink property var defaultAudioSink: Pipewire.defaultAudioSink
property int volume: defaultAudioSink && defaultAudioSink.audio && defaultAudioSink.audio.volume ? Math.round(defaultAudioSink.audio.volume * 100) : 0 property int volume: defaultAudioSink && defaultAudioSink.audio && defaultAudioSink.audio.volume ? Math.round(defaultAudioSink.audio.volume * 100) : 0