Files
dots-hyprland/dots/.config/quickshell/ii/modules/common/widgets/NotificationItem.qml
T
RamonBritoDev 6eaa869fac fix(qs): NotificationItem polish() loop on Qt 6.11
summaryText.Layout.fillWidth depended on summaryRow.implicitWidth,
its own parent RowLayout, creating a circular dependency:

  summaryText.Layout.fillWidth
    -> changes summaryText.width
    -> changes summaryRow.implicitWidth
    -> re-evaluates summaryText.Layout.fillWidth (loop)

Qt 6.10 tolerated this through layout settling heuristics, but
Qt 6.11.1 detects the loop and emits "ColumnLayout called polish()
inside updatePolish() of ColumnLayout" warnings repeatedly, pinning
CPU at 100% and freezing the sidebar/notification UI when opened.

Use root.width (the stable width inherited from the parent ListView)
as the reference for the elision threshold instead of the recursive
summaryRow.implicitWidth.
2026-05-21 16:23:40 +00:00

324 lines
14 KiB
QML

import qs
import qs.modules.common
import qs.services
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Qt5Compat.GraphicalEffects
import Quickshell
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: onlyNotification ? 0 : 8
property real summaryElideRatio: 0.85
property real dragConfirmThreshold: 70 // Drag further to discard notification
property real dismissOvershoot: notificationIcon.implicitWidth + 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 ? parentDragDistance :
Math.abs(parentDragDistance) > dragConfirmThreshold ? 0 :
dragIndexDiff == 1 ? (parentDragDistance * 0.3) :
dragIndexDiff == 2 ? (parentDragDistance * 0.1) : 0
implicitHeight: background.implicitHeight
function destroyWithAnimation(left = false) {
root.qmlParent.resetDrag()
background.anchors.leftMargin = background.anchors.leftMargin; // Break binding
destroyAnimation.left = left;
destroyAnimation.running = true;
}
TextMetrics {
id: summaryTextMetrics
font.pixelSize: root.fontSize
text: root.notificationObject.summary || ""
}
SequentialAnimation { // Drag finish animation
id: destroyAnimation
property bool left: true
running: false
NumberAnimation {
target: background.anchors
property: "leftMargin"
to: (root.width + root.dismissOvershoot) * (destroyAnimation.left ? -1 : 1)
duration: Appearance.animation.elementMove.duration
easing.type: Appearance.animation.elementMove.type
easing.bezierCurve: Appearance.animation.elementMove.bezierCurve
}
onFinished: () => {
Notifications.discardNotification(notificationObject.notificationId);
}
}
DragManager { // Drag manager
id: dragManager
anchors.fill: root
anchors.leftMargin: root.expanded ? -notificationIcon.implicitWidth : 0
interactive: expanded
automaticallyReset: false
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
onClicked: (mouse) => {
if (mouse.button === Qt.MiddleButton) {
root.destroyWithAnimation();
}
}
onDraggingChanged: () => {
if (dragging) {
root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root);
}
}
onDragDiffXChanged: () => {
root.qmlParent.dragDistance = dragDiffX;
}
onDragReleased: (diffX, diffY) => {
if (Math.abs(diffX) > root.dragConfirmThreshold)
root.destroyWithAnimation(diffX < 0);
else
dragManager.resetDrag();
}
}
NotificationAppIcon { // App icon
id: notificationIcon
opacity: (!onlyNotification && notificationObject.image != "" && expanded) ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
image: notificationObject.image
anchors.right: background.left
anchors.top: background.top
anchors.rightMargin: 10
}
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 && !onlyNotification) ?
(notificationObject.urgency == NotificationUrgency.Critical) ?
ColorUtils.mix(Appearance.colors.colSecondaryContainer, Appearance.colors.colLayer2, 0.35) :
(Appearance.colors.colLayer3) :
ColorUtils.transparentize(Appearance.colors.colLayer3)
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
StyledText {
id: summaryText
Layout.fillWidth: summaryTextMetrics.width >= root.width * root.summaryElideRatio
visible: !root.onlyNotification
font.pixelSize: root.fontSize
color: Appearance.colors.colOnLayer3
elide: Text.ElideRight
text: root.notificationObject.summary || ""
}
StyledText {
opacity: !root.expanded ? 1 : 0
visible: opacity > 0
Layout.fillWidth: true
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
font.pixelSize: root.fontSize
color: Appearance.colors.colSubtext
elide: Text.ElideRight
wrapMode: Text.Wrap // Needed for proper eliding????
maximumLineCount: 1
textFormat: Text.StyledText
text: {
return NotificationUtils.processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "<br/>")
}
}
}
ColumnLayout { // Expanded content
id: expandedContentColumn
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: {
return `<style>img{max-width:${expandedContentColumn.width}px;}</style>` +
`${NotificationUtils.processNotificationBody(notificationObject.body, notificationObject.appName || notificationObject.summary).replace(/\n/g, "<br/>")}`
}
onLinkActivated: (link) => {
Qt.openUrlExternally(link)
GlobalStates.sidebarRightOpen = false
}
PointingHandLinkHover {}
}
Item {
Layout.fillWidth: true
implicitWidth: actionsFlickable.implicitWidth
implicitHeight: actionsFlickable.implicitHeight
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: actionsFlickable.width
height: actionsFlickable.height
radius: Appearance.rounding.small
}
}
ScrollEdgeFade {
target: actionsFlickable
vertical: false
}
StyledFlickable { // Notification actions
id: actionsFlickable
anchors.fill: parent
implicitHeight: actionRowLayout.implicitHeight
contentWidth: actionRowLayout.implicitWidth
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
NotificationActionButton {
Layout.fillWidth: true
buttonText: Translation.tr("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.larger
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "close"
}
}
Repeater {
id: actionRepeater
model: notificationObject.actions
NotificationActionButton {
id: notifAction
required property var modelData
Layout.fillWidth: true
buttonText: modelData.text
urgency: notificationObject.urgency
onClicked: {
Notifications.attemptInvokeAction(notificationObject.notificationId, modelData.identifier);
}
}
}
NotificationActionButton {
Layout.fillWidth: true
urgency: notificationObject.urgency
implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) :
(contentItem.implicitWidth + leftPadding + rightPadding)
onClicked: {
Quickshell.clipboardText = 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.larger
horizontalAlignment: Text.AlignHCenter
color: (notificationObject.urgency == NotificationUrgency.Critical) ?
Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface
text: "content_copy"
}
}
}
}
}
}
}
}
}