Files
dotfiles/quickshell/overlays/Toasts.qml
T

161 lines
5.0 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Notifications
import "root:/config"
import "root:/components"
import "root:/services"
// Floating notification stack under the bar, top right. Replaces dunst.
PanelWindow {
id: root
required property var modelData
screen: modelData
// Only the primary bar screen shows toasts, to avoid duplicates.
readonly property bool primaryScreen: Quickshell.screens.length === 0
|| Quickshell.screens[0] === modelData
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "takemi-toasts"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
anchors { top: true; right: true }
margins {
top: Theme.barHeight + Theme.barMargin * 2
right: Theme.barMargin
}
implicitWidth: 400
implicitHeight: Math.max(1, stack.implicitHeight)
color: "transparent"
exclusionMode: ExclusionMode.Ignore
visible: primaryScreen && stack.children.length > 0
// Toasts currently on screen, newest first.
property list<Notification> live: []
property int maxLive: 5
Connections {
target: Notifs
enabled: root.primaryScreen
function onPopupRequested(notif) {
root.live = [notif, ...root.live].slice(0, root.maxLive);
}
}
function retire(notif) {
const idx = root.live.indexOf(notif);
if (idx === -1) return;
const next = root.live.slice();
next.splice(idx, 1);
root.live = next;
}
Column {
id: stack
anchors.right: parent.right
anchors.top: parent.top
width: parent.width
spacing: Theme.padS
Repeater {
model: root.live
Item {
id: toast
required property var modelData
width: stack.width
height: card.implicitHeight
NotifCard {
id: card
width: parent.width
notif: toast.modelData
onDismissed: {
Notifs.dismiss(toast.modelData);
root.retire(toast.modelData);
}
}
// Countdown rule that drains along the bottom edge.
//
// It used to be a full-bleed rectangle across the whole toast,
// which meant it ran straight through the card's chopped
// bottom-left corner and out the other side — a stray bar
// hanging off the card at the one place the silhouette is
// supposed to be cut. It lives inside the cut now, and ends
// where the leaning right edge does.
readonly property real fuseSpan: Math.max(0,
width - card.chop - card.implicitHeight * card.lean)
Skew {
id: fuse
anchors.bottom: parent.bottom
anchors.bottomMargin: 1
x: card.chop
height: 2
width: toast.fuseSpan
lean: card.lean
color: Theme.alpha(card.tone, 0.7)
visible: life.running
}
NumberAnimation {
id: life
target: fuse
property: "width"
from: toast.fuseSpan
to: 0
running: false
duration: Notifs.timeoutFor(toast.modelData)
easing.type: Easing.Linear
onFinished: root.retire(toast.modelData)
}
// Slide in from the right, Persona-style.
opacity: 0
transform: Translate { id: shove; x: 60 }
Component.onCompleted: {
enter.start();
const ms = Notifs.timeoutFor(toast.modelData);
if (ms > 0) life.start();
}
ParallelAnimation {
id: enter
NumberAnimation {
target: toast; property: "opacity"
to: 1; duration: Theme.durBase
}
NumberAnimation {
target: shove; property: "x"
from: 60; to: 0
duration: Theme.durSlow
easing.type: Easing.OutBack
}
}
// Hovering a toast pauses its fuse.
HoverHandler {
id: toastHover
onHoveredChanged: {
if (!life.duration) return;
if (hovered) life.pause();
else if (life.paused) life.resume();
}
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: root.retire(toast.modelData)
}
}
}
}
}