diff --git a/install.sh b/install.sh index 2237d30..222a1d1 100755 --- a/install.sh +++ b/install.sh @@ -105,6 +105,28 @@ download \ "$SCRIPT_DIR/modules/jl/assets/correct.svg" \ "correct.svg" +# ---------------------------- +# Notification sounds +# ---------------------------- +echo +info "Preparing notification sounds..." +mkdir -p "$SCRIPT_DIR/assets/sounds" + +download \ + "https://raw.githubusercontent.com/deepin-community/sound-theme-freedesktop/master/stereo/dialog-information.oga" \ + "$SCRIPT_DIR/assets/sounds/notification-low.oga" \ + "notification-low.oga" + +download \ + "https://raw.githubusercontent.com/deepin-community/sound-theme-freedesktop/master/stereo/dialog-warning.oga" \ + "$SCRIPT_DIR/assets/sounds/notification-normal.oga" \ + "notification-normal.oga" + +download \ + "https://raw.githubusercontent.com/deepin-community/sound-theme-freedesktop/master/stereo/bell.oga" \ + "$SCRIPT_DIR/assets/sounds/notification-critical.oga" \ + "notification-critical.oga" + # ---------------------------- # Nerd Font # ---------------------------- diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index 0fd8b7d..53488b0 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -3,10 +3,13 @@ import Quickshell import Quickshell.Hyprland import "./widgets" import "../../config" +import "../notifications" PanelWindow { id: panel + required property var manager + color: "transparent" anchors.top: true @@ -47,6 +50,8 @@ PanelWindow { WindowInfo {} + NotificationIndicator {manager: panel.manager} + } Row { // bar widgets center diff --git a/modules/bar/BarLoader.qml b/modules/bar/BarLoader.qml index 9ccc5ed..cb36067 100644 --- a/modules/bar/BarLoader.qml +++ b/modules/bar/BarLoader.qml @@ -4,11 +4,14 @@ import Quickshell Item { id: bar_root + + required property var manager + Repeater { // Bars model: Quickshell.screens delegate: Loader { active: true // loads object - sourceComponent: Bar {screen: modelData} // creates instance of bar component + sourceComponent: Bar {screen: modelData; manager: root.manager} // creates instance of bar component } } } \ No newline at end of file diff --git a/modules/notifications/NotificationCard.qml b/modules/notifications/NotificationCard.qml new file mode 100644 index 0000000..54716b9 --- /dev/null +++ b/modules/notifications/NotificationCard.qml @@ -0,0 +1,154 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Widgets +import "../../config" + +Rectangle { + id: card + + required property var notification + required property var manager + + width: parent.width + height: Math.max(64, cardContent.height + 24) + radius: Config.radius + + property var urgency: notification.urgency + property bool isCritical: urgency === NotificationUrgency.Critical + + color: { + if (isCritical) return Qt.alpha(Colors.errorContainer, 0.95) + if (urgency === NotificationUrgency.Low) return Qt.alpha(Colors.surfaceContainerHigh, 0.95) + return Qt.alpha(Colors.primaryContainer, 0.95) + } + + border { + width: Config.border_width + color: { + if (isCritical) return Colors.error + if (urgency === NotificationUrgency.Low) return Colors.outline + return Colors.primaryContainer_fg + } + } + + NumberAnimation on opacity { + id: fadeInAnim + from: 0 + to: 1 + duration: 200 + easing.type: Easing.OutCubic + } + + NumberAnimation on x { + id: slideInAnim + from: parent ? parent.width : 100 + to: 0 + duration: 300 + easing.type: Easing.OutCubic + } + + Component.onCompleted: { + fadeInAnim.start() + slideInAnim.start() + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onClicked: { + manager.dismissNotification(notification) + } + } + + Row { + id: cardContent + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: 12 + } + spacing: 10 + + Item { + id: iconContainer + width: 28 + height: 28 + anchors.verticalCenter: parent.verticalCenter + + IconImage { + id: notifIcon + anchors.fill: parent + icon: notification.appIcon + sourceSize.width: 28 + sourceSize.height: 28 + visible: notification.appIcon !== "" + } + + Text { + anchors.centerIn: parent + text: "\uF0F3" + font.pixelSize: 16 + font.family: Config.font + visible: !notifIcon.visible + color: Colors.primary_fg + } + } + + Column { + id: textColumn + width: parent.width - iconContainer.width - parent.spacing - 20 + spacing: 3 + anchors.verticalCenter: parent.verticalCenter + + Text { + id: summaryText + width: parent.width + text: notification.summary || notification.appName || "Notification" + font.pixelSize: 13 + font.bold: true + font.family: Config.font + color: { + if (isCritical) return Colors.error + if (urgency === NotificationUrgency.Low) return Colors.secondaryContainer_fg + return Colors.primary + } + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + id: bodyText + width: parent.width + text: notification.body || "" + visible: text !== "" + wrapMode: Text.WordWrap + font.pixelSize: 11 + font.family: Config.font + color: isCritical ? Colors.errorContainer_fg : Colors.surface_fg + maximumLineCount: 2 + elide: Text.ElideRight + } + } + + Text { + text: "\u00D7" + font.pixelSize: 16 + font.bold: true + color: isCritical ? Colors.error : Colors.secondary + anchors.verticalCenter: parent.verticalCenter + opacity: 0.6 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + manager.dismissNotification(notification) + } + } + } + } +} diff --git a/modules/notifications/NotificationIndicator.qml b/modules/notifications/NotificationIndicator.qml new file mode 100644 index 0000000..ee20793 --- /dev/null +++ b/modules/notifications/NotificationIndicator.qml @@ -0,0 +1,234 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import Quickshell.Widgets +import "../../config" + +MouseArea { + id: indicator + + required property var manager + + height: bar.height + width: indicatorRow.width + Config.spacing_fun(height) * 2 + + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + property bool popupOpen: false + + onClicked: { + popupOpen = !popupOpen + if (popupOpen) { + notifPopup.visible = true + } else { + notifPopup.visible = false + } + } + + Rectangle { + anchors.fill: parent + color: indicator.containsMouse || popupOpen ? Colors.tertiaryContainer : "transparent" + radius: Config.radius_fun(indicator.height) + border.width: (indicator.containsMouse || popupOpen) ? Config.border_width : 0 + border.color: (indicator.containsMouse || popupOpen) ? Colors.tertiaryContainer_fg : "transparent" + + Row { + id: indicatorRow + anchors.centerIn: parent + spacing: Config.spacing_fun(indicator.height) / 2 + + Item { + id: iconWrap + width: Config.iconSize(indicator.height) + height: Config.iconSize(indicator.height) + anchors.verticalCenter: parent.verticalCenter + + IconImage { + id: notifIcon + anchors.fill: parent + icon: manager.lastNotification ? manager.lastNotification.appIcon : "" + sourceSize.width: Config.iconSize(indicator.height) + sourceSize.height: Config.iconSize(indicator.height) + visible: manager.lastNotification && manager.lastNotification.appIcon !== "" + } + + Text { + anchors.centerIn: parent + text: "\uF0F3" + font.pixelSize: Config.iconSize(indicator.height) + font.family: Config.font + visible: !notifIcon.visible + color: indicator.containsMouse ? Colors.tertiary : Colors.secondary + } + } + + Rectangle { + id: countBadge + width: Math.max(badgeText.width + 6, 16) + height: 16 + radius: 8 + visible: manager.notificationCount > 0 + anchors.verticalCenter: parent.verticalCenter + color: manager.hasCritical ? Colors.error : Colors.primary + + Text { + id: badgeText + anchors.centerIn: parent + text: manager.notificationCount > 99 ? "99+" : manager.notificationCount.toString() + font.pixelSize: Config.font_size_tiny(indicator.height) + font.family: Config.font + font.bold: true + color: manager.hasCritical ? Colors.error_fg : Colors.primary_fg + } + } + + Rectangle { + id: criticalDot + width: 8 + height: 8 + radius: 4 + visible: manager.hasCritical + anchors.verticalCenter: parent.verticalCenter + color: Colors.error + + SequentialAnimation on opacity { + loops: Animation.Infinite + PropertyAnimation { from: 1.0; to: 0.4; duration: 800 } + PropertyAnimation { from: 0.4; to: 1.0; duration: 800 } + } + } + } + } + + PopupWindow { + id: notifPopup + visible: indicator.popupOpen + color: "transparent" + + anchor { + item: indicator + edges: Edges.Top | Edges.Left + gravity: Edges.Bottom | Edges.Right + adjustment: PopupAdjustment.FlipY | PopupAdjustment.SlideX + onAnchoring: { + anchor.rect.x = (indicator.width - notifPopup.width) / 2 + anchor.rect.y = indicator.height + 4 + } + } + + implicitWidth: 280 + implicitHeight: Math.max(60, popupContent.height + 24) + + Rectangle { + anchors.fill: parent + color: Qt.alpha(Colors.primaryContainer, 0.95) + radius: Config.radius + border { + width: Config.border_width + color: Colors.primaryContainer_fg + } + + Column { + id: popupContent + width: parent.width + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: 12 + } + spacing: 8 + + Text { + text: "Notifications" + font.pixelSize: Config.font_size_small(indicator.height) + font.family: Config.font + font.bold: true + color: Colors.primary + anchors.horizontalCenter: parent.horizontalCenter + visible: manager.notificationCount > 0 + } + + Repeater { + model: manager.notifications + + delegate: Rectangle { + width: parent.width + height: delegateText.height + 10 + radius: Config.small_radius + color: Qt.alpha(Colors.surfaceContainerHighest, 0.5) + + required property var model + + property var notif: model.object + + Row { + anchors { + left: parent.left + right: parent.right + margins: 6 + verticalCenter: parent.verticalCenter + } + spacing: 6 + + Rectangle { + width: 6 + height: 24 + radius: 3 + color: { + if (notif.urgency === NotificationUrgency.Critical) return Colors.error + if (notif.urgency === NotificationUrgency.Low) return Colors.outline + return Colors.primary + } + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - 24 + spacing: 2 + + Text { + id: delegateText + width: parent.width + text: notif.summary || notif.appName || "Notification" + font.pixelSize: Config.labelSize(indicator.height) + font.family: Config.font + font.bold: true + color: Colors.surface_fg + elide: Text.ElideRight + maximumLineCount: 1 + } + + Text { + width: parent.width + text: notif.body || "" + visible: text !== "" + font.pixelSize: Config.font_size_tiny(indicator.height) + font.family: Config.font + color: Colors.surfaceVariant_fg + elide: Text.ElideRight + maximumLineCount: 1 + } + } + } + } + } + + Text { + text: "No notifications" + font.pixelSize: Config.font_size_small(indicator.height) + font.family: Config.font + color: Colors.secondary + anchors.horizontalCenter: parent.horizontalCenter + visible: manager.notificationCount === 0 + height: 30 + } + } + } + + onVisibleChanged: { + if (!visible) indicator.popupOpen = false + } + } +} diff --git a/modules/notifications/NotificationManager.qml b/modules/notifications/NotificationManager.qml new file mode 100644 index 0000000..7faecbf --- /dev/null +++ b/modules/notifications/NotificationManager.qml @@ -0,0 +1,147 @@ +import QtQuick +import QtMultimedia +import Quickshell +import Quickshell.Services.Notifications +import "../../config" + +Item { + id: root + + NotificationServer { + id: notificationServer + imageSupported: true + actionsSupported: true + bodyMarkupSupported: false + + onNotification: (notification) => { + notification.tracked = true + root.onNotificationReceived(notification) + } + } + + property ObjectModel notifications: ObjectModel {} + property int notificationCount: 0 + property var lastNotification: null + property bool hasCritical: false + + readonly property string soundDir: Config.configDir + "/assets/sounds" + + property var _dismissTimes: ({}) + + function onNotificationReceived(notification) { + notifications.append(notification) + notificationCount = notifications.count + lastNotification = notification + if (notification.urgency === NotificationUrgency.Critical) + hasCritical = true + + playSound(notification.urgency) + scheduleDismiss(notification) + } + + function playSound(urgency) { + var path + var vol + switch (urgency) { + case NotificationUrgency.Low: + path = soundDir + "/notification-low.oga" + vol = 0.35 + break + case NotificationUrgency.Critical: + path = soundDir + "/notification-critical.oga" + vol = 1.0 + break + default: + path = soundDir + "/notification-normal.oga" + vol = 0.7 + break + } + + if (player.hasAudio) player.stop() + player.source = "file://" + path + audioOutput.volume = vol + player.play() + } + + function scheduleDismiss(notification) { + var timeout = notification.expireTimeout + if (timeout <= 0) { + if (notification.urgency === NotificationUrgency.Critical) + timeout = 60 + else + timeout = 5 + } + _dismissTimes[notification.id] = Date.now() / 1000 + timeout + if (!refreshTimer.running) + refreshTimer.running = true + } + + function dismissNotification(notification) { + for (var i = 0; i < notifications.count; i++) { + if (notifications.get(i).id === notification.id) { + notifications.remove(i) + break + } + } + notificationCount = notifications.count + delete _dismissTimes[notification.id] + if (notificationCount === 0) { + hasCritical = false + lastNotification = null + } else { + lastNotification = notifications.get(0) + hasCritical = false + for (var j = 0; j < notifications.count; j++) { + if (notifications.get(j).urgency === NotificationUrgency.Critical) { + hasCritical = true + break + } + } + } + notification.tracked = false + } + + function dismissById(nid) { + for (var i = 0; i < notifications.count; i++) { + var n = notifications.get(i) + if (n.id === nid) { + dismissNotification(n) + return + } + } + } + + function dismissAll() { + while (notifications.count > 0) { + var n = notifications.get(0) + dismissNotification(n) + } + } + + Timer { + id: refreshTimer + interval: 1000 + repeat: true + onTriggered: { + var now = Date.now() / 1000 + var toDismiss = [] + for (var i = 0; i < notifications.count; i++) { + var n = notifications.get(i) + var t = _dismissTimes[n.id] + if (t !== undefined && now >= t) + toDismiss.push(n) + } + for (var j = 0; j < toDismiss.length; j++) + dismissNotification(toDismiss[j]) + if (notifications.count === 0) + running = false + } + } + + MediaPlayer { + id: player + audioOutput: AudioOutput { + id: audioOutput + } + } +} diff --git a/modules/notifications/NotificationPopup.qml b/modules/notifications/NotificationPopup.qml new file mode 100644 index 0000000..4f92459 --- /dev/null +++ b/modules/notifications/NotificationPopup.qml @@ -0,0 +1,71 @@ +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Wayland +import Quickshell.Services.Notifications +import "../../config" + +Variants { + id: root + + required property var manager + + model: Quickshell.screens + + delegate: PanelWindow { + id: popupWindow + screen: modelData + color: "transparent" + + anchors.top: true + anchors.right: true + + width: 380 + height: Math.min(implicitHeight, screen.height * 0.6) + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "notification-popup" + + margins { + top: 8 + right: 8 + } + + readonly property var hyprMonitor: Hyprland.monitorFor(screen) + visible: hyprMonitor === Hyprland.focusedMonitor + + implicitHeight: contentColumn.height + + Connections { + target: Hyprland + function onFocusedMonitorChanged() { + popupWindow.visible = popupWindow.hyprMonitor === Hyprland.focusedMonitor + } + } + + Connections { + target: root.manager.notifications + function onCountChanged() { + popupWindow.visible = popupWindow.hyprMonitor === Hyprland.focusedMonitor + && root.manager.notifications.count > 0 + } + } + + Column { + id: contentColumn + width: parent.width + spacing: 8 + topPadding: 4 + + Repeater { + model: root.manager.notifications + + delegate: NotificationCard { + width: contentColumn.width + notification: model.object + manager: root.manager + } + } + } + } +} diff --git a/modules/notifications/qmldir b/modules/notifications/qmldir new file mode 100644 index 0000000..19e0863 --- /dev/null +++ b/modules/notifications/qmldir @@ -0,0 +1,4 @@ +NotificationManager 1.0 NotificationManager.qml +NotificationPopup 1.0 NotificationPopup.qml +NotificationIndicator 1.0 NotificationIndicator.qml +NotificationCard 1.0 NotificationCard.qml diff --git a/shell.qml b/shell.qml index 8506bd7..a34566b 100644 --- a/shell.qml +++ b/shell.qml @@ -3,17 +3,24 @@ import Quickshell //for PanelWindow import Quickshell.Hyprland import QtQuick //for Text +import Quickshell.Services.Notifications import "./modules/bar" import "./modules/wp" import "./modules/wlogout" import "./modules/jl" import "./modules/background" +import "./modules/notifications" ShellRoot { id: root + // Notifications + NotificationManager { id: notificationManager } + + NotificationPopup { manager: notificationManager } + // Bar - BarLoader {} + BarLoader { manager: notificationManager } //Wallpaper picker WallpaperPickerLoader {}