diff options
Diffstat (limited to 'notifications')
| -rw-r--r-- | notifications/Balloons.qml | 72 | ||||
| -rw-r--r-- | notifications/NotificationBalloon.qml | 160 | ||||
| l--------- | notifications/Notify.qml | 1 | ||||
| -rw-r--r-- | notifications/README.md | 35 | ||||
| l--------- | notifications/Theme.qml | 1 | ||||
| -rwxr-xr-x | notifications/notify-actions.sh | 40 | ||||
| -rw-r--r-- | notifications/shell.qml | 30 |
7 files changed, 339 insertions, 0 deletions
diff --git a/notifications/Balloons.qml b/notifications/Balloons.qml new file mode 100644 index 0000000..3676e67 --- /dev/null +++ b/notifications/Balloons.qml @@ -0,0 +1,72 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland +import QtQuick + +// The balloon stack, bottom-right of DP-1, over conky. +Scope { + id: root + + // A tick drives both expiry and the end of a snooze without waiting for a + // file change. 250ms keeps ronema's -t 1 near-instant. + property double now: Date.now() + Timer { + interval: 250 + running: true + repeat: true + onTriggered: root.now = Date.now() + } + + // The Repeater model is Notify.queue itself, so its identity only changes + // when the daemon rewrites the file, not on the 250ms tick. Expiry and + // snooze are the balloon's own business: the tick reassigns `now`, each + // balloon re-evaluates its visible binding and leaves the column at + // expiry. Rebuilding the model on the tick would recreate every delegate + // (reloading icons, resetting hover) four times a second. + readonly property var live: Notify.queue || [] + + PanelWindow { + id: win + + // From the queue, never from layout: a hidden window stops polishing, + // and Column computes implicitHeight during polish, so deriving + // visibility from it would never turn true again after the first + // hide. Queue membership is a plain property the binding re-reads + // whether or not the window is mapped, so a later notification + // re-maps it. Expired entries that stay in the queue keep a + // zero-height window mapped, which is harmless. + visible: root.live.length > 0 + screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] + anchors { bottom: true; right: true } + margins { bottom: 12; right: 12 } + implicitWidth: 340 + implicitHeight: column.implicitHeight + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-notifications" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + Column { + id: column + width: parent.width + anchors { bottom: parent.bottom; right: parent.right } + spacing: 8 + + Repeater { + model: root.live + NotificationBalloon { notification: modelData; now: root.now } + } + } + } +} diff --git a/notifications/NotificationBalloon.qml b/notifications/NotificationBalloon.qml new file mode 100644 index 0000000..49ec0b4 --- /dev/null +++ b/notifications/NotificationBalloon.qml @@ -0,0 +1,160 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// One balloon: icon at the left, app, summary and body, an X, and the click +// targets. The body is markup, which is why the daemon advertises body-markup. +Rectangle { + id: b + + // Neither property is `required` on purpose. A required property makes + // QML create the delegate in its own context, where `modelData` and + // `index` are not defined, so the Repeater's `notification: modelData` + // silently arrives undefined and every binding on it fails. + property var notification + property double now: 0 + + // Driven by the shell's tick. The balloon drops itself once its expiry + // passes, or while a suppression says so: the drawer and snooze withhold + // everything, DND withholds low and normal but still shows critical. The + // drawer lists a notification DND chose not to pop, because a list the + // user opened is not an interruption. + visible: { + if (Notify.drawerOpen) return false; + if (Notify.snoozeUntil > b.now) return false; + if (Notify.dnd && b.notification.urgency !== "critical") return false; + if (b.notification.expires !== 0 && b.now >= b.notification.expires) return false; + return true; + } + + width: parent ? parent.width : 340 + implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0) + radius: 10 + color: Qt.alpha(Theme.base, 0.82) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + // The background click target is declared first so the X, declared later, + // sits above it and wins its corner. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + Notify.closeAll(); + return; + } + const acts = b.notification.actions || []; + const inert = b.notification.expires !== 0 && Date.now() >= b.notification.expires; + if (acts.length > 0 && !inert) Notify.actions(b.notification.id, acts); + else Notify.close(b.notification.id); + } + } + + Image { + id: icon + visible: b.notification.icon !== "" && b.notification.icon !== undefined + anchors { left: parent.left; top: parent.top; margins: 10 } + width: 32 + height: 32 + source: visible ? "file://" + b.notification.icon : "" + sourceSize { width: 64; height: 64 } + } + + Text { + id: close + anchors { right: parent.right; top: parent.top; margins: 6 } + width: 20 + height: 20 + text: "\uf00d" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { family: Theme.iconFamily; pixelSize: 13 } + color: closeArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: closeArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.close(b.notification.id) + } + } + + Column { + id: texts + anchors { + left: icon.visible ? icon.right : parent.left + leftMargin: 10 + right: parent.right + rightMargin: 10 + top: parent.top + topMargin: 10 + } + spacing: 2 + + Text { + width: parent.width + text: b.notification.app || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } + color: Theme.subtext + } + + Text { + width: parent.width + text: b.notification.summary || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2 } + color: Theme.text + } + + Text { + width: parent.width + visible: text !== "" + text: Notify.sanitize(b.notification.body) + textFormat: Text.RichText + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize } + color: Theme.subtext + } + } + + // The content image (a screenshot or an app-provided image), below the + // text. The daemon writes the path; an older daemon without the field + // leaves this hidden. The height matches the scaled width so + // PreserveAspectFit does not letterbox, and a tall screenshot is capped at + // 240px. Asynchronous so a large screenshot does not stall the shell. + Image { + id: preview + visible: b.notification.image !== "" && b.notification.image !== undefined + anchors { + left: parent.left + right: parent.right + top: texts.bottom + leftMargin: 10 + rightMargin: 10 + topMargin: 8 + } + height: visible && implicitWidth > 0 + ? Math.min(width * implicitHeight / implicitWidth, 240) + : 0 + source: visible ? "file://" + b.notification.image : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + } +} diff --git a/notifications/Notify.qml b/notifications/Notify.qml new file mode 120000 index 0000000..635f31d --- /dev/null +++ b/notifications/Notify.qml @@ -0,0 +1 @@ +../shared/Notify.qml
\ No newline at end of file diff --git a/notifications/README.md b/notifications/README.md new file mode 100644 index 0000000..91fb758 --- /dev/null +++ b/notifications/README.md @@ -0,0 +1,35 @@ +# notifications + +The balloon renderer for the notification daemon (`notifyd`, a separate repo). +It reads the daemon's published files through the `Notify` singleton and draws +one balloon per live notification, bottom-right of `DP-1`. + +## Suppression lives here + +The daemon does not know about DND or snooze. This component withholds +balloons: `status.dnd` suppresses low and normal, `notifyd/snooze` suppresses +everything. The drawer's reserved space lists every live notification anyway. + +## The files are the interface + + $XDG_RUNTIME_DIR/notifyd/queue.json the live queue + $XDG_RUNTIME_DIR/notifyd/history.json the ring of 20 + $XDG_RUNTIME_DIR/notifyd/drawer "1" while the drawer holds the space + $XDG_RUNTIME_DIR/notifyd/snooze an epoch second while snoozing + +`notifyctl` and `notify-snooze.sh` are in `~/bin`; without them the balloons +draw but close and actions do nothing. + +## Blur + +Hyprland blurs a layer surface only when a rule names its namespace. This +component sets `quickshell-notifications`; the rule is in +`~/.config/hypr/sections/decorations.lua`. + +## History rows + +The spec calls history rows closable individually. `notifyctl` has no per-id +history delete, only `clear-history`, so the history page ships a clear all +and inert rows. The daemon verb and the renderer row it needs are tracked in +the `notifyd` repo's `TODO.md`; when that ships, the row's X is wired to +`notifyctl history-remove`. diff --git a/notifications/Theme.qml b/notifications/Theme.qml new file mode 120000 index 0000000..3d2e40f --- /dev/null +++ b/notifications/Theme.qml @@ -0,0 +1 @@ +../shared/Theme.qml
\ No newline at end of file diff --git a/notifications/notify-actions.sh b/notifications/notify-actions.sh new file mode 100755 index 0000000..651275c --- /dev/null +++ b/notifications/notify-actions.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# Pick one of a notification's actions with rofi and invoke it. The renderer +# passes the labels and keys as arguments, so nothing here parses JSON. +# +# notify-actions.sh <id> <label> <key> [label key ...] + +set -u + +[[ $# -ge 3 ]] || { echo "usage: ${0##*/} <id> <label> <key> ..." >&2; exit 2; } + +id="$1"; shift +labels=() +keys=() +while [[ $# -ge 2 ]]; do + labels+=("$1") + keys+=("$2") + shift 2 +done + +choice="$(printf '%s\n' "${labels[@]}" | rofi -dmenu -i -p "Notification")" +[[ -n "$choice" ]] || exit 0 + +for i in "${!labels[@]}"; do + if [[ "${labels[$i]}" == "$choice" ]]; then + notifyctl action "$id" "${keys[$i]}" + exit $? + fi +done diff --git a/notifications/shell.qml b/notifications/shell.qml new file mode 100644 index 0000000..1b353a3 --- /dev/null +++ b/notifications/shell.qml @@ -0,0 +1,30 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland + +ShellRoot { + // Quickshell exits once no window is visible, and the balloons are + // hidden whenever the queue is empty, so this keeps the shell alive. See + // AGENTS.md. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + Balloons {} +} |
