notifications: groups

This commit is contained in:
end-4
2025-05-25 22:47:31 +02:00
parent 85d3bc4444
commit 837a2a06fa
11 changed files with 801 additions and 708 deletions
@@ -79,7 +79,7 @@ function mix(color1, color2, percentage) {
* @param {number} percentage - The amount to transparentize (0-1).
* @returns {Qt.rgba} The resulting color.
*/
function transparentize(color, percentage) {
function transparentize(color, percentage = 1) {
var c = Qt.color(color);
return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage));
}
@@ -30,7 +30,9 @@ MouseArea { // Flick to dismiss
onPressed: (mouse) => {
if (!root.interactive) {
mouse.accepted = false;
if (mouse.button === Qt.LeftButton) {
mouse.accepted = false;
}
return;
}
if (mouse.button === Qt.LeftButton) {
@@ -40,7 +42,6 @@ MouseArea { // Flick to dismiss
}
onReleased: (mouse) => {
if (!root.interactive) {
mouse.accepted = false;
return;
}
dragging = false
@@ -51,7 +52,6 @@ MouseArea { // Flick to dismiss
}
onPositionChanged: (mouse) => {
if (!root.interactive) {
mouse.accepted = false;
return;
}
if (mouse.buttons & Qt.LeftButton) {
@@ -64,7 +64,6 @@ MouseArea { // Flick to dismiss
}
onCanceled: (mouse) => {
if (!root.interactive) {
mouse.accepted = false;
return;
}
released(mouse);
@@ -0,0 +1,104 @@
import "root:/modules/common"
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.Notifications
Rectangle { // App icon
id: root
property var appIcon: ""
property var summary: ""
property var urgency: NotificationUrgency.Normal
property var image: ""
property real size: 45
property real materialIconScale: 0.57
property real appIconScale: 0.7
property real smallAppIconScale: 0.49
property real materialIconSize: size * materialIconScale
property real appIconSize: size * appIconScale
property real smallAppIconSize: size * smallAppIconScale
implicitWidth: size
implicitHeight: size
radius: Appearance.rounding.full
color: Appearance.m3colors.m3secondaryContainer
Loader {
id: materialSymbolLoader
active: root.appIcon == ""
anchors.fill: parent
sourceComponent: MaterialSymbol {
text: {
const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("")
const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(root.summary)
return (root.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ?
"release_alert" : guessedIcon
}
anchors.fill: parent
color: (root.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) :
Appearance.m3colors.m3onSecondaryContainer
iconSize: root.materialIconSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Loader {
id: appIconLoader
active: root.image == "" && root.appIcon != ""
anchors.centerIn: parent
sourceComponent: IconImage {
id: appIconImage
implicitSize: root.appIconSize
asynchronous: true
source: Quickshell.iconPath(root.appIcon, "image-missing")
}
}
Loader {
id: notifImageLoader
active: root.image != ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: notifImage
anchors.fill: parent
readonly property int size: parent.width
source: root.image
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
width: size
height: size
sourceSize.width: size
sourceSize.height: size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: notifImage.size
height: notifImage.size
radius: Appearance.rounding.full
}
}
}
Loader {
id: notifImageAppIconLoader
active: root.appIcon != ""
anchors.bottom: parent.bottom
anchors.right: parent.right
sourceComponent: IconImage {
implicitSize: root.smallAppIconSize
asynchronous: true
source: Quickshell.iconPath(root.appIcon, "image-missing")
}
}
}
}
}
@@ -0,0 +1,232 @@
import "root:/modules/common"
import "root:/services"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Hyprland
import Quickshell.Services.Notifications
/**
* A group of notifications from the same app.
* Similar to Android's notifications
*/
Item { // Notification group area
id: root
property var notificationGroup
property var notifications: notificationGroup.notifications
property int notificationCount: notifications.length
property bool multipleNotifications: notificationCount > 1
property bool expanded: false
property bool popup: false
property real padding: 10
implicitHeight: background.implicitHeight
property real dragConfirmThreshold: 70 // Drag further to discard notification
property real dismissOvershoot: 20 // Account for gaps and bouncy animations
property var qmlParent: root.parent.parent // There's something between this and the parent ListView
property var parentDragIndex: qmlParent.dragIndex
property var parentDragDistance: qmlParent.dragDistance
property var dragIndexDiff: Math.abs(parentDragIndex - index)
property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) :
parentDragDistance > dragConfirmThreshold ? 0 :
dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) :
dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0
function destroyWithAnimation() {
root.qmlParent.resetDrag()
background.anchors.leftMargin = background.anchors.leftMargin; // Break binding
destroyAnimation.running = true;
}
SequentialAnimation { // Drag finish animation
id: destroyAnimation
running: false
NumberAnimation {
target: background.anchors
property: "leftMargin"
to: root.width + root.dismissOvershoot
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
onFinished: () => {
root.notifications.forEach((notif) => {
Notifications.discardNotification(notif.id);
});
}
}
function toggleExpanded() {
if (expanded) implicitHeightAnim.enabled = true;
else implicitHeightAnim.enabled = false;
root.expanded = !root.expanded;
}
DragManager { // Drag manager
id: dragManager
anchors.fill: parent
interactive: !expanded
automaticallyReset: false
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
root.toggleExpanded();
}
}
onDraggingChanged: () => {
if (dragging) {
root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root);
}
}
onDragDiffXChanged: () => {
root.qmlParent.dragDistance = dragDiffX;
}
onDragReleased: (diffX, diffY) => {
if (diffX > root.dragConfirmThreshold)
root.destroyWithAnimation();
else
dragManager.resetDrag();
}
}
Rectangle { // Background of the notification
id: background
anchors.left: parent.left
width: parent.width
color: Appearance.m3colors.m3surfaceContainer
radius: Appearance.rounding.normal
anchors.leftMargin: root.xOffset
Behavior on anchors.leftMargin {
enabled: !dragManager.dragging
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
clip: true
implicitHeight: expanded ?
row.implicitHeight + padding * 2 :
Math.min(80, row.implicitHeight + padding * 2)
Behavior on implicitHeight {
id: implicitHeightAnim
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
layer.enabled: true
layer.effect: MultiEffect {
source: background
anchors.fill: background
shadowEnabled: popup
shadowColor: Appearance.colors.colShadow
shadowVerticalOffset: 1
shadowBlur: 0.5
}
RowLayout { // Left column for icon, right column for content
id: row
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: root.padding
spacing: 10
NotificationAppIcon { // Icons
Layout.alignment: Qt.AlignTop
Layout.fillWidth: false
appIcon: notificationGroup.appIcon
summary: notificationGroup.notifications[root.notificationCount - 1].summary
}
ColumnLayout { // Content
Layout.fillWidth: true
spacing: expanded ? 5 : 0
Behavior on spacing {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout { // App name (or summary when there's only 1 notif) and time
id: topRow
spacing: 0
property real fontSize: Appearance.font.pixelSize.smaller
property bool showAppName: root.multipleNotifications
StyledText {
id: appName
text: topRow.showAppName ?
notificationGroup.appName :
notificationGroup.notifications[0].summary
font.pixelSize: topRow.showAppName ?
topRow.fontSize :
Appearance.font.pixelSize.small
color: topRow.showAppName ?
Appearance.colors.colSubtext :
Appearance.colors.colOnLayer2
}
StyledText {
id: timeText
text: " • " + NotificationUtils.getFriendlyNotifTimeString(notificationGroup.time)
font.pixelSize: topRow.fontSize
color: Appearance.colors.colSubtext
Layout.alignment: Qt.AlignRight
Layout.fillWidth: true
}
Item { Layout.fillWidth: true }
NotificationGroupExpandButton {
count: root.notificationCount
expanded: root.expanded
fontSize: topRow.fontSize
onClicked: { root.toggleExpanded() }
}
}
StyledListView { // Notification body (expanded)
id: notificationsColumn
implicitHeight: contentHeight
Layout.fillWidth: true
spacing: expanded ? 5 : 3
// clip: true
interactive: false
Behavior on spacing {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
model: ScriptModel {
// values: root.expanded ? root.notifications : root.notifications.slice(0, 2)
values: root.notifications.slice().reverse()
}
delegate: NotificationItem {
required property int index
required property var modelData
notificationObject: modelData
expanded: root.expanded
onlyNotification: (root.notificationCount === 1)
opacity: (!root.expanded && index == 1 && root.notificationCount > 2) ? 0.5 : 1
visible: root.expanded || (index < 2)
anchors.left: parent?.left
anchors.right: parent?.right
}
}
}
}
}
}
@@ -0,0 +1,51 @@
import "root:/modules/common"
import "root:/services"
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
RippleButton { // Expand button
id: root
required property int count
required property bool expanded
property real fontSize: Appearance.font.pixelSize.small
implicitHeight: fontSize + 4 * 2
implicitWidth: Math.max(contentItem.implicitWidth + 5 * 2, 30)
Layout.alignment: Qt.AlignVCenter
Layout.fillHeight: false
buttonRadius: Appearance.rounding.full
colBackground: ColorUtils.mix(Appearance.colors.colLayer2, Appearance.colors.colLayer2Hover, 0.5)
colBackgroundHover: Appearance.colors.colLayer2Hover
colRipple: Appearance.colors.colLayer2Active
contentItem: Item {
anchors.centerIn: parent
implicitWidth: contentRow.implicitWidth
RowLayout {
id: contentRow
anchors.centerIn: parent
spacing: 3
StyledText {
Layout.leftMargin: 4
visible: root.count > 1
text: root.count
font.pixelSize: root.fontSize
}
MaterialSymbol {
text: "keyboard_arrow_down"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer2
rotation: expanded ? 180 : 0
Behavior on rotation {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
}
}
}
@@ -0,0 +1,268 @@
import "root:/modules/common"
import "root:/services"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import "./notification_utils.js" as NotificationUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Hyprland
import Quickshell.Services.Notifications
Item { // Notification item area
id: root
property var notificationObject
property bool expanded: false
property bool onlyNotification: false
property real fontSize: Appearance.font.pixelSize.small
property real padding: 8
property real dragConfirmThreshold: 70 // Drag further to discard notification
property real dismissOvershoot: 20 // Account for gaps and bouncy animations
property var qmlParent: root?.parent?.parent // There's something between this and the parent ListView
property var parentDragIndex: qmlParent?.dragIndex ?? -1
property var parentDragDistance: qmlParent?.dragDistance ?? 0
property var dragIndexDiff: Math.abs(parentDragIndex - index)
property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) :
parentDragDistance > dragConfirmThreshold ? 0 :
dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) :
dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0
implicitHeight: background.implicitHeight
function destroyWithAnimation() {
root.qmlParent.resetDrag()
background.anchors.leftMargin = background.anchors.leftMargin; // Break binding
destroyAnimation.running = true;
}
SequentialAnimation { // Drag finish animation
id: destroyAnimation
running: false
NumberAnimation {
target: background.anchors
property: "leftMargin"
to: root.width + root.dismissOvershoot
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
onFinished: () => {
Notifications.discardNotification(notificationObject.id);
}
}
DragManager { // Drag manager
id: dragManager
anchors.fill: parent
interactive: expanded
automaticallyReset: false
acceptedButtons: Qt.LeftButton
onPressAndHold: (mouse) => {
if (mouse.button === Qt.LeftButton) {
Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`)
notificationSummaryText.text = String.format(qsTr("{0} (copied)"), notificationObject.summary)
}
}
onDraggingChanged: () => {
if (dragging) {
root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root);
}
}
onDragDiffXChanged: () => {
root.qmlParent.dragDistance = dragDiffX;
}
onDragReleased: (diffX, diffY) => {
if (diffX > root.dragConfirmThreshold)
root.destroyWithAnimation();
else
dragManager.resetDrag();
}
}
Rectangle { // Background of notification item
id: background
width: parent.width
anchors.left: parent.left
radius: Appearance.rounding.small
anchors.leftMargin: root.xOffset
Behavior on anchors.leftMargin {
enabled: !dragManager.dragging
NumberAnimation {
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial
}
}
color: expanded ?
(notificationObject.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, Appearance.colors.colLayer2, 0.35) :
(Appearance.m3colors.m3surfaceContainerHigh) :
ColorUtils.transparentize(Appearance.m3colors.m3surfaceContainerHighest)
implicitHeight: expanded ? (contentColumn.implicitHeight + padding * 2) : summaryRow.implicitHeight
Behavior on implicitHeight {
animation: Appearance.animation.elementMove.numberAnimation.createObject(this)
}
ColumnLayout { // Content column
id: contentColumn
anchors.fill: parent
anchors.margins: expanded ? root.padding : 0
spacing: 3
Behavior on anchors.margins {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout { // Summary row
id: summaryRow
visible: !root.onlyNotification || !root.expanded
Layout.fillWidth: true
implicitHeight: summaryText.implicitHeight
// Layout.fillWidth: true
StyledText {
id: summaryText
visible: !root.onlyNotification
font.pixelSize: root.fontSize
color: Appearance.colors.colOnLayer2
elide: Text.ElideRight
text: root.notificationObject.summary || ""
}
StyledText {
opacity: !root.expanded ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Layout.fillWidth: true
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
elide: Text.ElideRight
textFormat: Text.StyledText
text: notificationObject.body.replace(/<img/g, "\n <img").split("\n")[0]
}
}
ColumnLayout { // Expanded content
Layout.fillWidth: true
opacity: root.expanded ? 1 : 0
visible: opacity > 0
StyledText { // Notification body (expanded)
id: notificationBodyText
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Layout.fillWidth: true
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
wrapMode: Text.Wrap
elide: Text.ElideRight
textFormat: Text.RichText
text: `<style>img{max-width:${notificationBodyText.width}px;}</style>` +
`${notificationObject.body.replace(/\n/g, "<br/>")}`
}
Flickable { // Notification actions
id: actionsFlickable
Layout.fillWidth: true
implicitHeight: actionRowLayout.implicitHeight
contentWidth: actionRowLayout.implicitWidth
clip: true
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on height {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout {
id: actionRowLayout
Layout.alignment: Qt.AlignBottom
Repeater {
id: actionRepeater
model: notificationObject.actions
NotificationActionButton {
Layout.fillWidth: true
buttonText: modelData.text
urgency: notificationObject.urgency
onClicked: {
Notifications.attemptInvokeAction(notificationObject.id, modelData.identifier);
}
}
}
NotificationActionButton {
Layout.fillWidth: true
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`)
copyIcon.text = "inventory"
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: {
copyIcon.text = "content_copy"
}
}
contentItem: MaterialSymbol {
id: copyIcon
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "content_copy"
}
}
NotificationActionButton {
Layout.fillWidth: true
buttonText: qsTr("Close")
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
root.destroyWithAnimation()
}
contentItem: MaterialSymbol {
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "close"
}
}
}
}
}
}
}
}
@@ -0,0 +1,31 @@
import "root:/"
import "root:/modules/common/"
import "root:/modules/common/widgets"
import "root:/services"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
StyledListView { // Scrollable window
id: root
property bool popup: false
spacing: 3
model: ScriptModel {
values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList
}
delegate: NotificationGroup {
required property int index
required property var modelData
popup: root.popup
anchors.left: parent?.left
anchors.right: parent?.right
notificationGroup: popup ?
Notifications.popupGroupsByAppName[modelData] :
Notifications.groupsByAppName[modelData]
}
}
@@ -1,577 +0,0 @@
import "root:/modules/common"
import "root:/services"
import "root:/modules/common/functions/string_utils.js" as StringUtils
import "root:/modules/common/functions/color_utils.js" as ColorUtils
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Widgets
import Quickshell.Hyprland
import Quickshell.Services.Notifications
import "./notification_utils.js" as NotificationUtils
Item {
id: root
property var notificationObject
property bool popup: false
property bool expanded: false
property bool enableAnimation: true
property int notificationListSpacing: 5
property int defaultTimeoutValue: 5000
property var notificationXAnimation: Appearance.animation.elementMoveEnter
Layout.fillWidth: true
clip: !popup
implicitHeight: notificationColumnLayout.implicitHeight + notificationListSpacing
Component.onCompleted: {
if (popup) timeoutTimer.start()
}
Timer {
id: timeoutTimer
interval: notificationObject.expireTimeout ?? root.defaultTimeoutValue
repeat: false
onTriggered: {
root.notificationXAnimation = Appearance.animation.elementMoveExit
Notifications.timeoutNotification(notificationObject.id);
}
}
function destroyWithAnimation(delay = 0) {
destroyTimer0.interval = delay
destroyTimer0.start()
}
function toggleExpanded() {
root.enableAnimation = true
notificationRowWrapper.anchors.bottom = undefined
root.expanded = !root.expanded
}
Timer {
id: destroyTimer0
interval: 0
repeat: false
onTriggered: {
notificationRowWrapper.anchors.left = undefined
notificationRowWrapper.anchors.right = undefined
notificationRowWrapper.anchors.fill = undefined
notificationBackground.anchors.left = undefined
notificationBackground.anchors.right = undefined
notificationBackground.anchors.fill = undefined
notificationRowWrapper.x = width + (Appearance.sizes.hyprlandGapsOut + Appearance.sizes.elevationMargin) * 2 // Account for shadow
notificationBackground.x = width + (Appearance.sizes.hyprlandGapsOut + Appearance.sizes.elevationMargin) * 2 // Account for shadow
destroyTimer1.start()
}
}
Timer {
id: destroyTimer1
interval: notificationXAnimation.duration
repeat: false
onTriggered: {
notificationRowWrapper.anchors.top = undefined
notificationRowWrapper.anchors.bottom = root.bottom
Notifications.discardNotification(notificationObject.id);
}
}
MouseArea {
// Middle click to close
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button == Qt.MiddleButton)
root.destroyWithAnimation()
else if (mouse.button == Qt.RightButton)
root.toggleExpanded()
}
// Flick right to dismiss/discard
property real startX: 0
property real dragStartThreshold: 10
property real dragConfirmThreshold: 70
property bool dragStarted: false
onPressed: (mouse) => {
if (mouse.button === Qt.LeftButton) {
startX = mouse.x
}
}
onPressAndHold: (mouse) => {
if (mouse.button === Qt.LeftButton) {
Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`)
notificationSummaryText.text = String.format(qsTr("{0} (copied)"), notificationObject.summary)
}
}
onDragStartedChanged: () => {
// Prevent drag focus being shifted to parent flickable
if (root.parent.parent.parent.interactive !== undefined) root.parent.parent.parent.interactive = !dragStarted
root.enableAnimation = !dragStarted
}
onReleased: (mouse) => {
dragStarted = false
if (mouse.button === Qt.LeftButton) {
if (notificationRowWrapper.x > dragConfirmThreshold) {
root.notificationXAnimation = Appearance.animation.elementMoveEnter
root.destroyWithAnimation()
} else {
// Animate back if not far enough
root.notificationXAnimation = Appearance.animation.elementMoveFast
notificationRowWrapper.x = 0
notificationBackground.x = 0
}
}
}
onCanceled: (mouse) => {
dragStarted = false
if (notificationRowWrapper.x > dragConfirmThreshold) {
root.notificationXAnimation = Appearance.animation.elementMoveEnter
root.destroyWithAnimation()
} else {
// Animate back if not far enough
root.notificationXAnimation = Appearance.animation.elementMoveFast
notificationRowWrapper.x = 0
notificationBackground.x = 0
}
}
onPositionChanged: (mouse) => {
if (mouse.buttons & Qt.LeftButton) {
let dx = mouse.x - startX
if (dragStarted || dx > dragStartThreshold) {
dragStarted = true
notificationRowWrapper.anchors.left = undefined
notificationRowWrapper.anchors.right = undefined
notificationRowWrapper.anchors.fill = undefined
notificationBackground.anchors.left = undefined
notificationBackground.anchors.right = undefined
notificationBackground.anchors.fill = undefined
notificationRowWrapper.x = Math.max(0, dx)
notificationBackground.x = Math.max(0, dx)
}
}
}
}
// Background
Item {
id: notificationBackgroundWrapper
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.topMargin: notificationListSpacing
implicitHeight: notificationColumnLayout.implicitHeight + notificationListSpacing
Rectangle {
id: notificationBackground
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
// anchors.top: parent.top
implicitHeight: notificationColumnLayout.implicitHeight
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, Appearance.colors.colLayer2, 0.35) : Appearance.colors.colLayer2
radius: Appearance.rounding.normal
layer.enabled: true
layer.effect: MultiEffect {
source: notificationBackground
anchors.fill: notificationBackground
shadowEnabled: popup
shadowColor: Appearance.colors.colShadow
shadowVerticalOffset: 1
shadowBlur: 0.5
}
Behavior on x {
enabled: enableAnimation
NumberAnimation {
duration: root.notificationXAnimation.duration
easing.type: root.notificationXAnimation.type
easing.bezierCurve: root.notificationXAnimation.bezierCurve
}
}
}
}
Item {
id: notificationRowWrapper
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
// anchors.top: parent.top
implicitHeight: notificationColumnLayout.implicitHeight + notificationListSpacing
Behavior on x {
enabled: enableAnimation
NumberAnimation {
duration: root.notificationXAnimation.duration
easing.type: root.notificationXAnimation.type
easing.bezierCurve: root.notificationXAnimation.bezierCurve
}
}
ColumnLayout {
id: notificationColumnLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
spacing: 0
Item {
Layout.fillWidth: true
implicitHeight: notificationRowLayout.implicitHeight
Behavior on implicitHeight {
enabled: enableAnimation
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
RowLayout {
id: notificationRowLayout
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
Rectangle { // App icon
id: iconRectangle
implicitWidth: 47
implicitHeight: 47
Layout.leftMargin: 10
Layout.topMargin: 10
Layout.bottomMargin: 10
Layout.alignment: Qt.AlignTop
Layout.fillWidth: false
radius: Appearance.rounding.full
color: Appearance.m3colors.m3secondaryContainer
Loader {
id: materialSymbolLoader
active: notificationObject.appIcon == ""
anchors.fill: parent
sourceComponent: MaterialSymbol {
text: {
const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("")
const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(notificationObject.summary)
return (notificationObject.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ?
"release_alert" : guessedIcon
}
anchors.fill: parent
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) :
Appearance.m3colors.m3onSecondaryContainer
iconSize: 27
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Loader {
id: appIconLoader
active: notificationObject.image == "" && notificationObject.appIcon != ""
anchors.centerIn: parent
sourceComponent: IconImage {
implicitSize: 33
asynchronous: true
source: Quickshell.iconPath(notificationObject.appIcon, "image-missing")
}
}
Loader {
id: notifImageLoader
active: notificationObject.image != ""
anchors.fill: parent
sourceComponent: Item {
anchors.fill: parent
Image {
id: notifImage
anchors.fill: parent
readonly property int size: parent.width
source: notificationObject?.image
fillMode: Image.PreserveAspectCrop
cache: false
antialiasing: true
asynchronous: true
width: size
height: size
sourceSize.width: size
sourceSize.height: size
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: notifImage.size
height: notifImage.size
radius: Appearance.rounding.full
}
}
}
Loader {
id: notifImageAppIconLoader
active: notificationObject.appIcon != ""
anchors.bottom: parent.bottom
anchors.right: parent.right
sourceComponent: IconImage {
implicitSize: 23
asynchronous: true
source: Quickshell.iconPath(notificationObject.appIcon, "image-missing")
}
}
}
}
}
ColumnLayout { // Notification content
spacing: 0
Layout.fillWidth: true
RowLayout { // Row of summary, time and expand button
Layout.topMargin: 10
Layout.leftMargin: 10
Layout.rightMargin: 10
Layout.fillWidth: true
StyledText { // Summary
id: notificationSummaryText
Layout.fillWidth: true
horizontalAlignment: Text.AlignLeft
verticalAlignment: Text.AlignBottom
font.pixelSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer2
text: notificationObject.summary
wrapMode: expanded ? Text.Wrap : Text.NoWrap
elide: Text.ElideRight
}
CircularProgress {
id: notificationProgress
visible: popup
Layout.alignment: Qt.AlignVCenter
lineWidth: 2
value: popup ? 1 : 0
size: 20
animationDuration: notificationObject.expireTimeout ?? root.defaultTimeoutValue
easingType: Easing.Linear
Component.onCompleted: {
value = 0
}
}
StyledText { // Time
id: notificationTimeText
Layout.fillWidth: false
Layout.alignment: Qt.AlignTop
Layout.topMargin: 3
wrapMode: Text.Wrap
horizontalAlignment: Text.AlignLeft
font.pixelSize: Appearance.font.pixelSize.smaller
color: Appearance.m3colors.m3outline
text: NotificationUtils.getFriendlyNotifTimeString(notificationObject.time)
Connections {
target: DateTime
function onTimeChanged() {
notificationTimeText.text = NotificationUtils.getFriendlyNotifTimeString(notificationObject.time)
}
}
}
RippleButton { // Expand button
Layout.alignment: Qt.AlignTop
id: expandButton
implicitWidth: 22
implicitHeight: 22
buttonRadius: Appearance.rounding.full
colBackgroundHover: Appearance.colors.colLayer2Hover
colRipple: Appearance.colors.colLayer2Active
onClicked: {
root.toggleExpanded()
}
contentItem: MaterialSymbol {
anchors.centerIn: parent
text: "keyboard_arrow_down"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
iconSize: Appearance.font.pixelSize.normal
color: Appearance.colors.colOnLayer2
rotation: expanded ? 180 : 0
Behavior on rotation {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
}
}
}
StyledText { // Notification body
id: notificationBodyText
Layout.fillWidth: true
Layout.leftMargin: 10
Layout.rightMargin: 10
Layout.bottomMargin: 10
clip: true
wrapMode: expanded ? Text.Wrap : Text.NoWrap
elide: Text.ElideRight
font.pixelSize: Appearance.font.pixelSize.small
horizontalAlignment: Text.AlignLeft
color: Appearance.m3colors.m3outline
textFormat: expanded ? Text.RichText : Text.StyledText
text: expanded
? `<style>img{max-width:${notificationBodyText.width}px;}</style>` +
`${notificationObject.body.replace(/\n/g, "<br/>")}`
: notificationObject.body.replace(/<img/g, "\n <img").split("\n")[0]
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
Hyprland.dispatch("global quickshell:sidebarRightClose")
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton // Only for hover
hoverEnabled: true
cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor
}
}
}
}
}
// Actions
Flickable {
id: actionsFlickable
Layout.fillWidth: true
// Layout.topMargin: -5
Layout.leftMargin: 10
Layout.rightMargin: 10
Layout.bottomMargin: expanded ? 10 : 0
implicitHeight: expanded ? actionRowLayout.implicitHeight : 0
height: expanded ? actionRowLayout.implicitHeight : 0
contentWidth: actionRowLayout.implicitWidth
clip: true
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: actionsFlickable.width
height: actionsFlickable.height
radius: Appearance.rounding.small
}
}
opacity: expanded ? 1 : 0
visible: opacity > 0
Behavior on opacity {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
Behavior on height {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
Behavior on implicitHeight {
NumberAnimation {
duration: Appearance.animation.elementMoveFast.duration
easing.type: Appearance.animation.elementMoveFast.type
easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve
}
}
RowLayout {
id: actionRowLayout
Layout.alignment: Qt.AlignBottom
Repeater {
id: actionRepeater
model: notificationObject.actions
NotificationActionButton {
Layout.fillWidth: true
buttonText: modelData.text
urgency: notificationObject.urgency
onClicked: {
Notifications.attemptInvokeAction(notificationObject.id, modelData.identifier);
}
}
}
NotificationActionButton {
Layout.fillWidth: true
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? (actionsFlickable.width / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`)
copyIcon.text = "inventory"
copyIconTimer.restart()
}
Timer {
id: copyIconTimer
interval: 1500
repeat: false
onTriggered: {
copyIcon.text = "content_copy"
}
}
contentItem: MaterialSymbol {
id: copyIcon
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "content_copy"
}
}
NotificationActionButton {
Layout.fillWidth: true
buttonText: qsTr("Close")
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? (actionsFlickable.width / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
root.destroyWithAnimation()
}
contentItem: MaterialSymbol {
iconSize: Appearance.font.pixelSize.large
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "close"
}
}
}
}
}
}
}
@@ -0,0 +1,106 @@
import "root:/"
import "root:/modules/common/"
import "root:/modules/common/widgets"
import "root:/services"
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
ListView { // Scrollable window
id: root
spacing: 5
property real removeOvershoot: 20 // Account for gaps and bouncy animations
property int dragIndex: -1
property real dragDistance: 0
function resetDrag() {
root.dragIndex = -1
root.dragDistance = 0
}
add: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
from: 0,
to: 1,
}),
]
}
addDisplaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
displaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
move: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
moveDisplaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
remove: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "x",
to: root.width + root.removeOvershoot,
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "opacity",
to: 0,
})
]
}
// This is movement when something is removed, not removing animation!
removeDisplaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
}
@@ -34,74 +34,14 @@ Scope {
color: "transparent"
implicitWidth: Appearance.sizes.notificationPopupWidth
ListView { // Scrollable window
NotificationListView {
id: listview
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
anchors.topMargin: 5
implicitWidth: parent.width - Appearance.sizes.elevationMargin * 2
add: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
from: 0,
to: 1,
}),
]
}
addDisplaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
displaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
]
}
move: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
]
}
remove: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "x",
to: listview.width,
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "opacity",
to: 0,
})
]
}
model: ScriptModel {
values: Notifications.popupList.slice().reverse()
}
delegate: NotificationWidget {
required property var modelData
id: notificationWidget
popup: true
anchors.left: parent?.left
anchors.right: parent?.right
notificationObject: modelData
}
popup: true
}
}
}
@@ -11,7 +11,7 @@ import Quickshell.Widgets
Item {
id: root
ListView { // Scrollable window
NotificationListView { // Scrollable window
id: listview
anchors.left: parent.left
anchors.right: parent.right
@@ -28,68 +28,7 @@ Item {
}
}
add: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
from: 0,
to: 1,
}),
]
}
addDisplaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
properties: "opacity,scale",
to: 1,
}),
]
}
displaced: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
]
}
move: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "y",
}),
]
}
remove: Transition {
animations: [
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "x",
to: listview.width,
}),
Appearance.animation.elementMove.numberAnimation.createObject(this, {
property: "opacity",
to: 0,
})
]
}
model: ScriptModel {
values: Notifications.list.slice().reverse()
}
delegate: NotificationWidget {
required property var modelData
id: notificationWidget
// anchors.horizontalCenter: parent.horizontalCenter
anchors.left: parent?.left
anchors.right: parent?.right
Layout.fillWidth: true
notificationObject: modelData
}
popup: false
}
// Placeholder when list is empty