nova-shell/modules/NotifCard.qml
2026-04-17 11:31:09 +02:00

276 lines
9.1 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Services.Notifications
import "." as M
// Shared notification card: background, progress bar, urgency bar, icon, text, dismiss button.
// Does NOT include dismiss animation or dismiss logic — emits dismissRequested() instead.
Item {
id: root
required property var notif // NotifItem (may be null — all accesses use ?.)
property bool showAppName: true
property bool dismissOnAction: true
property int iconSize: 32
property int bodyMaxLines: 3
property color accentColor: M.Theme.base0D
signal dismissRequested
// Tall enough for content including padding, or the icon if it's taller
implicitHeight: Math.max(_contentCol.implicitHeight, _icon.visible ? _icon.height : 0) + 16
HoverHandler {
id: _hover
}
TapHandler {
acceptedButtons: Qt.RightButton
cursorShape: Qt.PointingHandCursor
onTapped: root.dismissRequested()
}
// Background: base01, base02 on hover
Rectangle {
anchors.fill: parent
color: _hover.hovered ? M.Theme.base02 : M.Theme.base01
opacity: _hover.hovered ? 1.0 : Math.max(M.Theme.barOpacity, 0.9)
radius: M.Theme.radius
Behavior on color {
ColorAnimation {
duration: 100
}
}
// Progress bar fill (hint value)
Rectangle {
visible: (root.notif?.hints?.value ?? -1) >= 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
width: parent.width * Math.min(1, Math.max(0, (root.notif?.hints?.value ?? 0) / 100))
color: M.Theme.base03
radius: parent.radius
Behavior on width {
NumberAnimation {
duration: 200
}
}
}
}
// Urgency accent bar
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: 3
radius: 1
color: {
const u = root.notif?.urgency ?? NotificationUrgency.Normal;
return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D;
}
}
// App icon / image
Image {
id: _icon
anchors.left: parent.left
anchors.leftMargin: 14
anchors.top: parent.top
anchors.topMargin: 8
width: root.iconSize
height: root.iconSize
source: {
const img = root.notif?.image;
if (img)
return img;
const ic = root.notif?.appIcon;
if (!ic)
return "";
return (ic.startsWith("/") || ic.startsWith("file://")) ? ic : Quickshell.iconPath(ic, "dialog-information");
}
visible: status === Image.Ready
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(root.iconSize, root.iconSize)
asynchronous: true
}
// Dismiss button — overlays top-right, visible only on hover (opacity keeps layout stable)
Text {
id: _dismissBtn
anchors.right: parent.right
anchors.rightMargin: 10
anchors.top: parent.top
anchors.topMargin: 8
text: "\uF00D"
color: _dismissHover.hovered ? M.Theme.base08 : M.Theme.base03
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.iconFontFamily
opacity: _hover.hovered ? 1 : 0
HoverHandler {
id: _dismissHover
}
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: root.dismissRequested()
}
}
Column {
id: _contentCol
anchors.left: _icon.visible ? _icon.right : parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: _icon.visible ? 8 : 14
anchors.rightMargin: 20
anchors.topMargin: 8
spacing: 2
// Text section — tappable for default action
Item {
id: _textSection
width: parent.width
height: _textCol.implicitHeight
implicitHeight: _textCol.implicitHeight
TapHandler {
cursorShape: root.notif?.actions?.some(a => a.identifier === "default") ? Qt.PointingHandCursor : undefined
onTapped: {
const def = root.notif?.actions?.find(a => a.identifier === "default");
if (def) {
def.invoke();
if (root.dismissOnAction)
root.dismissRequested();
}
}
}
Column {
id: _textCol
width: parent.width
spacing: 2
// App name + time row (optional)
Row {
visible: root.showAppName
width: parent.width
Text {
text: root.notif?.appName ?? "Notification"
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
width: parent.width - _timeText.implicitWidth - 4
elide: Text.ElideRight
}
Text {
id: _timeText
text: root.notif?.timeStr ?? ""
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
}
// Summary (with inline time when app name row is hidden)
Row {
visible: !root.showAppName
width: parent.width
Text {
text: root.notif?.summary ?? ""
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
width: parent.width - _inlineTime.implicitWidth - 4
}
Text {
id: _inlineTime
text: root.notif?.timeStr ?? ""
color: M.Theme.base03
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
anchors.verticalCenter: parent.verticalCenter
}
}
Text {
visible: root.showAppName
width: parent.width
text: root.notif?.summary ?? ""
color: M.Theme.base05
font.pixelSize: M.Theme.fontSize
font.family: M.Theme.fontFamily
font.bold: true
elide: Text.ElideRight
wrapMode: Text.WordWrap
maximumLineCount: 2
}
Text {
width: parent.width
text: root.notif?.body ?? ""
color: M.Theme.base04
font.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.fontFamily
elide: Text.ElideRight
wrapMode: Text.WordWrap
maximumLineCount: root.bodyMaxLines
visible: text !== ""
}
}
}
// Action buttons — filter "default" (click-notification convention) and empty labels
Row {
spacing: 6
visible: _actionRepeater.count > 0
Repeater {
id: _actionRepeater
model: (root.notif?.actions ?? []).filter(a => a.text && a.identifier !== "default")
delegate: Rectangle {
required property var modelData
width: _actText.implicitWidth + 12
height: _actText.implicitHeight + 6
radius: M.Theme.radius
color: _actHover.hovered ? M.Theme.base03 : "transparent"
border.color: M.Theme.base03
border.width: 1
Text {
id: _actText
anchors.centerIn: parent
text: parent.modelData.text
color: root.accentColor
font.pixelSize: M.Theme.fontSize - 2
font.family: M.Theme.fontFamily
}
HoverHandler {
id: _actHover
}
TapHandler {
cursorShape: Qt.PointingHandCursor
onTapped: {
parent.modelData.invoke();
if (root.dismissOnAction)
root.dismissRequested();
}
}
}
}
}
}
}