Files
quickdots/bar/modules/Notifications.qml
2026-04-24 06:21:56 +00:00

427 lines
17 KiB
QML

import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Notifications
import "../../config" as Cfg
import "../../notifications" as Notif
Rectangle {
id: root
property real panelRadius: Cfg.Config.radius / 2
property int borderWidth: Cfg.Config.panelBorderWidth
property bool isTop: true
property var targetScreen: null
property int barHeight: Cfg.Config.barHeight
property int barMargin: Cfg.Config.margin
readonly property var t: Cfg.Config.theme
property int unreadCount: 0
property bool doNotDisturb: false
property bool historyOpen: historyPopup.popupOpen
width: 35
height: 40
radius: panelRadius
color: hoverHandler.hovered ? t.notifBgHover : t.notifBg
border.color: root.doNotDisturb
? t.notifDnd
: ((hoverHandler.hovered || historyOpen) ? t.notifBorderActive : t.notifBorder)
border.width: borderWidth
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
HoverHandler { id: hoverHandler; cursorShape: Qt.PointingHandCursor }
TapHandler {
onTapped: {
if (historyPopup.popupOpen) {
historyPopup.close()
} else {
root.unreadCount = 0
historyPopup.open()
}
}
}
Item {
anchors.centerIn: parent
width: Cfg.Config.notifIconSize + 10
height: Cfg.Config.notifIconSize + 10
Text {
id: bellIcon
anchors.centerIn: parent
text: root.doNotDisturb ? "󰂛" : "󰂚"
color: root.doNotDisturb
? t.notifDnd
: (root.unreadCount > 0 ? t.notifIconActive : t.notifIcon)
font.pixelSize: Cfg.Config.notifIconSize
Behavior on color { ColorAnimation { duration: 150 } }
}
Rectangle {
visible: root.unreadCount > 0
anchors.top: parent.top
anchors.right: parent.right
width: Math.max(14, badgeLabel.implicitWidth + 6)
height: 14
radius: 7
color: t.notifBadgeBg
Behavior on width { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
Text {
id: badgeLabel
anchors.centerIn: parent
text: root.unreadCount > 99 ? "99+" : root.unreadCount.toString()
color: t.notifBadgeText
font.pixelSize: 8
font.bold: true
}
}
}
ListModel { id: notifHistory }
ListModel { id: activeToasts }
function removeToast(toastId) {
for (var i = 0; i < activeToasts.count; i++) {
if (activeToasts.get(i).toastId === toastId) {
activeToasts.remove(i)
return
}
}
}
function urgencyColor(urgency) {
if (urgency === NotificationUrgency.Critical) return t.notifUrgencyCritical
if (urgency === NotificationUrgency.Normal) return t.notifUrgencyNormal
return t.notifUrgencyLow
}
function urgencyIcon(urgency) {
if (urgency === NotificationUrgency.Critical) return "󰀪"
if (urgency === NotificationUrgency.Normal) return "󰋼"
return "󰵙"
}
NotificationServer {
id: notifServer
keepOnReload: false
actionIconsSupported: true
bodyMarkupSupported: true
bodySupported: true
persistenceSupported: true
imageSupported: true
onNotification: function(notif) {
notif.tracked = true
var timeStr = new Date().toLocaleTimeString(Qt.locale(), "HH:mm")
var toastId = notif.id + "_" + Date.now()
notifHistory.insert(0, {
nid: notif.id,
appName: notif.appName || "Unknown",
summary: notif.summary || "",
body: notif.body || "",
urgency: notif.urgency,
timeStr: timeStr
})
while (notifHistory.count > Cfg.Config.notificationHistorySize)
notifHistory.remove(notifHistory.count - 1)
var cfgTimeout = notif.urgency === NotificationUrgency.Critical
? Cfg.Config.notifTimeoutCritical
: (notif.urgency === NotificationUrgency.Low
? Cfg.Config.notifTimeoutLow
: Cfg.Config.notifTimeoutNormal)
var finalTimeout = (notif.expireTimeout > 0) ? notif.expireTimeout : cfgTimeout
var toastEntry = {
toastId: toastId,
nid: notif.id,
appName: notif.appName || "Unknown",
summary: notif.summary || "",
body: notif.body || "",
urgency: notif.urgency,
timeStr: timeStr,
timeout: finalTimeout
}
if (!root.doNotDisturb) {
if (toastLayer.atBottom) activeToasts.append(toastEntry)
else activeToasts.insert(0, toastEntry)
if (!root.historyOpen) root.unreadCount++
}
}
}
PanelWindow {
id: toastLayer
screen: root.targetScreen
visible: activeToasts.count > 0
color: "transparent"
readonly property string pos: Cfg.Config.notificationPosition
readonly property bool atRight: pos === "topright" || pos === "bottomright"
readonly property bool atBottom: pos === "bottomleft" || pos === "bottomright"
anchors { top: true; bottom: true; right: atRight; left: !atRight }
implicitWidth: 350 + root.barMargin + 4
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "main-shell-notifications-toasts"
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
mask: Region { item: toastCol }
readonly property bool shareEdge: (root.isTop && !atBottom) || (!root.isTop && atBottom)
readonly property int edgeGap: shareEdge ? root.barMargin + root.barHeight + 10 : root.barMargin + 10
Column {
id: toastCol
x: !toastLayer.atRight ? 4 : 4
y: !toastLayer.atBottom ? toastLayer.edgeGap : parent.height - toastLayer.edgeGap - toastCol.height
spacing: 8
width: 350
Repeater {
model: activeToasts
Notif.NotificationCard {
width: toastCol.width
toastId: model.toastId
appName: model.appName
summary: model.summary
body: model.body
urgency: model.urgency
timeStr: model.timeStr
timeout: model.timeout
t: root.t
urgencyColor: root.urgencyColor
urgencyIcon: root.urgencyIcon
onDismissed: (id) => root.removeToast(id)
}
}
}
}
PanelWindow {
id: historyPopup
screen: root.targetScreen
visible: false
color: "transparent"
property bool popupOpen: false
function open() { hideTimer.stop(); visible = true; popupOpen = true }
function close() { popupOpen = false; hideTimer.restart() }
Timer {
id: hideTimer
interval: Cfg.Config.popupAnimDuration + 70
onTriggered: historyPopup.visible = false
}
anchors { top: true; bottom: true; left: true; right: true }
WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "main-shell-notifications-history"
WlrLayershell.exclusiveZone: -1
mask: popupOpen ? null : _noInput
Region { id: _noInput }
MouseArea {
anchors.fill: parent
onClicked: historyPopup.close()
}
Item {
id: histClipContainer
readonly property int cardW: 340
readonly property int screenPad: root.barMargin + 10
x: {
var cx = root.mapToItem(null, 0, 0).x + root.width / 2
return Math.max(screenPad, Math.min(cx - cardW / 2, parent.width - cardW - screenPad))
}
width: cardW
anchors.top: root.isTop ? parent.top : undefined
anchors.bottom: root.isTop ? undefined : parent.bottom
anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0
anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10)
height: parent.height - root.barHeight - 10
clip: true
Rectangle {
id: historyCard
width: parent.width
height: Math.min(histHeader.implicitHeight + 56 + (notifHistory.count > 0 ? notifHistory.count * 76 : 90), 520)
anchors.top: root.isTop ? parent.top : undefined
anchors.bottom: root.isTop ? undefined : parent.bottom
transform: Translate {
y: historyPopup.popupOpen ? 0 : (root.isTop ? -historyCard.height : historyCard.height)
Behavior on y {
NumberAnimation {
duration: Cfg.Config.popupAnimDuration;
easing.type: Easing.OutExpo
}
enabled: historyPopup.visible
}
}
opacity: historyPopup.popupOpen ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 200 } }
color: t.notifHistoryBg
radius: Cfg.Config.popupRadius
border.color: t.notifHistoryBorder
border.width: Cfg.Config.popupBorderWidth
layer.enabled: true
MouseArea { anchors.fill: parent; propagateComposedEvents: false }
RowLayout {
id: histHeader
anchors { left: parent.left; right: parent.right; top: parent.top }
anchors.topMargin: 20; anchors.leftMargin: 20; anchors.rightMargin: 20
spacing: 10
Text { text: "󰂚"; color: t.notifToastAppName; font.pixelSize: 18; Layout.alignment: Qt.AlignVCenter }
Text {
text: "Notifications"; color: t.notifToastAppName; font.pixelSize: 15; font.bold: true
Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter
}
// ── Do Not Disturb toggle ─────────────────────────────
Rectangle {
Layout.alignment: Qt.AlignVCenter
width: dndRow.implicitWidth + 16
height: 24
radius: 12
color: root.doNotDisturb
? Qt.rgba(t.notifDnd.r, t.notifDnd.g, t.notifDnd.b, 0.18)
: (dndHover.hovered ? t.notifHistoryHover : "transparent")
border.color: root.doNotDisturb ? t.notifDnd : "transparent"
border.width: Cfg.Config.popupBorderWidth
Behavior on color { ColorAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: 150 } }
Row {
id: dndRow
anchors.centerIn: parent
spacing: 4
Text {
text: "󰂛"
color: root.doNotDisturb ? t.notifDnd : t.notifToastDim
font.pixelSize: 12
anchors.verticalCenter: parent.verticalCenter
Behavior on color { ColorAnimation { duration: 150 } }
}
Text {
text: root.doNotDisturb ? "On" : "Off"
color: root.doNotDisturb ? t.notifDnd : t.notifToastDim
font.pixelSize: 11
font.bold: root.doNotDisturb
anchors.verticalCenter: parent.verticalCenter
Behavior on color { ColorAnimation { duration: 150 } }
}
}
HoverHandler { id: dndHover; cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: root.doNotDisturb = !root.doNotDisturb }
}
Rectangle {
visible: notifHistory.count > 0
Layout.alignment: Qt.AlignVCenter
width: clearText.implicitWidth + 16
height: 24
radius: 12
color: clearHover.hovered ? t.notifHistoryHover : "transparent"
Behavior on color { ColorAnimation { duration: 150 } }
Text { id: clearText; anchors.centerIn: parent; text: "Clear all"; color: t.notifToastDim; font.pixelSize: 11 }
HoverHandler { id: clearHover; cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: clearAllAnim.start() }
}
}
Rectangle {
id: histDivider
anchors { left: parent.left; right: parent.right; top: histHeader.bottom }
anchors.topMargin: 12; height: 1; color: t.notifSeparator
}
SequentialAnimation {
id: clearAllAnim
ParallelAnimation {
NumberAnimation {
target: histCol; property: "opacity"
to: 0; duration: 220; easing.type: Easing.OutCubic
}
NumberAnimation {
target: clearShift; property: "y"
to: -16; duration: 220; easing.type: Easing.OutCubic
}
}
ScriptAction {
script: { notifHistory.clear(); root.unreadCount = 0 }
}
ParallelAnimation {
NumberAnimation { target: histCol; property: "opacity"; to: 1; duration: 0 }
NumberAnimation { target: clearShift; property: "y"; to: 0; duration: 0 }
}
}
Flickable {
id: histFlick
anchors { left: parent.left; right: parent.right; top: histDivider.bottom; bottom: parent.bottom }
anchors.leftMargin: 2; anchors.rightMargin: 8
anchors.topMargin: 6; anchors.bottomMargin: 8; clip: true
contentHeight: histCol.implicitHeight
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: histCol
opacity: 1
transform: Translate { id: clearShift; y: 0 }
width: histFlick.width; spacing: 2; topPadding: 4; bottomPadding: 4; leftPadding: 2; rightPadding: 8
Item {
width: histCol.width - 16; height: 90; visible: notifHistory.count === 0
ColumnLayout {
anchors.centerIn: parent; spacing: 8
Text { text: "󰂚"; color: t.notifHistoryEmpty; font.pixelSize: 30; Layout.alignment: Qt.AlignHCenter }
Text { text: "No notifications"; color: t.notifHistoryEmpty; font.pixelSize: 12; Layout.alignment: Qt.AlignHCenter }
}
}
Repeater {
model: notifHistory
Notif.HistoryCard {
width: histCol.width - 10; x: 2; itemIndex: index
appName: model.appName; summary: model.summary; body: model.body; urgency: model.urgency; timeStr: model.timeStr
t: root.t; urgencyColor: root.urgencyColor; urgencyIcon: root.urgencyIcon
onDismissed: (idx) => notifHistory.remove(idx)
}
}
}
}
}
}
}
}