quickshell: move notification toast into bar surface

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
rope 2026-05-27 16:18:38 +01:00
parent cb3716a1ec
commit 98560bbbef

View file

@ -239,7 +239,6 @@ in
-- Layer rules: blur behind bar and toasts
hl.layer_rule({ match = { namespace = "quickshell-bar" }, blur = true, ignore_alpha = 0.3 })
hl.layer_rule({ match = { namespace = "quickshell-toast" }, blur = true, ignore_alpha = 0.3 })
-- Startup
hl.on("hyprland.start", function()
@ -515,7 +514,6 @@ in
singleton Theme 1.0 Theme.qml
singleton Commands 1.0 Commands.qml
Bar 1.0 Bar.qml
NotificationToast 1.0 NotificationToast.qml
'';
};
@ -590,12 +588,9 @@ in
Bar {
notifServer: _notifServer
shellRoot: root
}
}
NotificationToast {
shellRoot: root
}
}
'';
};
@ -619,6 +614,7 @@ in
id: bar
required property var modelData
required property NotificationServer notifServer
required property var shellRoot
screen: modelData
WlrLayershell.namespace: "quickshell-bar"
@ -640,6 +636,12 @@ in
width: activeDropdown && activeDropdown.visible ? activeDropdown.width : 0
height: activeDropdown && activeDropdown.visible ? activeDropdown.height : 0
}
Region {
x: toastItem.visible ? toastItem.x : 0
y: toastItem.visible ? toastItem.y : 0
width: toastItem.visible ? toastItem.width : 0
height: toastItem.visible ? toastItem.height : 0
}
}
Rectangle {
@ -651,23 +653,35 @@ in
color: Theme.barBg
}
// Bar bottom border left segment (up to dropdown gap)
// The "gap source" for the bar border dropdown takes priority, then toast
property bool hasGap: (activeDropdown && activeDropdown.dropdownHeight > 0)
|| (toastItem.visible && _toastRect.height > 0)
property real gapLeft: activeDropdown && activeDropdown.dropdownHeight > 0
? activeDropdown.x
: toastItem.visible && _toastRect.height > 0
? toastItem.x : 0
property real gapRight: activeDropdown && activeDropdown.dropdownHeight > 0
? activeDropdown.x + activeDropdown.width
: toastItem.visible && _toastRect.height > 0
? toastItem.x + toastItem.width : 0
property bool gapAlignRight: activeDropdown ? activeDropdown.alignRight : false
// Bar bottom border left segment (up to gap)
Rectangle {
id: barBorderLeft
x: 0; y: 30
width: (!activeDropdown || activeDropdown.dropdownHeight <= 0)
? bar.width : activeDropdown.x
width: bar.hasGap ? bar.gapLeft : bar.width
height: 1
color: Theme.base03
}
// Bar bottom border right segment (after dropdown gap)
// Bar bottom border right segment (after gap)
Rectangle {
id: barBorderRight
visible: activeDropdown && activeDropdown.dropdownHeight > 0 && !activeDropdown.alignRight
x: activeDropdown ? activeDropdown.x + activeDropdown.width : 0
visible: bar.hasGap && !bar.gapAlignRight
x: bar.gapRight
y: 30
width: activeDropdown ? bar.width - x : 0
width: bar.width - x
height: 1
color: Theme.base03
}
@ -2186,257 +2200,245 @@ in
}
}
}
}
'';
};
"quickshell/NotificationToast.qml" = {
onChange = qsRestart;
text = ''
import Quickshell
import Quickshell.Wayland
import Quickshell.Io
import QtQuick
PanelWindow {
id: notifToast
required property var shellRoot
screen: Quickshell.screens[0]
property var currentNotif: null
property bool open: false
readonly property var mutedApps: ["discord", "Discord", "Spotify", "spotify", "vlc", "mpv"]
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "quickshell-toast"
exclusionMode: ExclusionMode.Ignore
Process {
id: notifSoundProc
command: [Commands.notifSound, "-i", "message"]
}
Connections {
target: shellRoot
function onNotificationReceived() {
notifToast.show(shellRoot.latestNotification);
}
}
function show(notification) {
currentNotif = notification;
visible = true;
open = true;
_toastTimer.restart();
if (!mutedApps.includes(notification.appName)) {
notifSoundProc.running = true;
}
}
function dismiss() {
open = false;
_toastCloseDelay.start();
}
anchors.top: true
margins.top: 30
visible: false
implicitWidth: 320 + 16
implicitHeight: _toastRect.height + 4
color: "transparent"
Timer {
id: _toastTimer
interval: 5000
onTriggered: notifToast.dismiss()
}
Timer {
id: _toastCloseDelay
interval: 230
onTriggered: { notifToast.visible = false; notifToast.open = false; }
}
HoverHandler {
onHoveredChanged: {
if (hovered) _toastTimer.stop();
else _toastTimer.restart();
}
}
// Left inverse corner ear
// Notification Toast (only on primary screen)
Item {
anchors.right: _toastRect.left
anchors.top: parent.top
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.stroke();
}
}
}
id: toastItem
visible: false
property var currentNotif: null
property bool toastOpen: false
readonly property var mutedApps: ["discord", "Discord", "Spotify", "spotify", "vlc", "mpv"]
readonly property bool isPrimary: bar.screen === Quickshell.screens[0]
// Right inverse corner ear
Item {
anchors.left: _toastRect.right
anchors.top: parent.top
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.stroke();
}
}
}
x: Math.round(bar.width / 2 - width / 2)
y: 30
width: _toastLeftEar.width + _toastRect.width + _toastRightEar.width
height: _toastRect.height + 4
Rectangle {
id: _toastRect
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
width: 320
height: notifToast.open ? toastCol.height + 16 : 0
color: Theme.barBg
radius: 8
topLeftRadius: 0
topRightRadius: 0
clip: true
// Border outline (sides + bottom with rounded corners)
Canvas {
anchors.fill: parent
onPaint: {
var ctx = getContext("2d");
var w = width, h = height, r = 8;
ctx.clearRect(0, 0, w, h);
if (h < 1) return;
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0.5, r);
ctx.lineTo(0.5, h - r);
ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true);
ctx.lineTo(w - r - 0.5, h - 0.5);
ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true);
ctx.lineTo(w - 0.5, r);
ctx.stroke();
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
Process {
id: notifSoundProc
command: [Commands.notifSound, "-i", "message"]
}
Behavior on height {
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Column {
id: toastCol
anchors.left: parent.left
anchors.right: toastDismiss.left
anchors.top: parent.top
anchors.margins: 8
spacing: 2
Text {
width: parent.width
text: notifToast.currentNotif ? (notifToast.currentNotif.summary || notifToast.currentNotif.appName) : ""
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 12
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: notifToast.currentNotif ? (notifToast.currentNotif.body || "") : ""
color: Theme.base04
font.family: "FiraMono Nerd Font"
font.pixelSize: 11
elide: Text.ElideRight
maximumLineCount: 3
wrapMode: Text.Wrap
visible: text !== ""
}
Row {
spacing: 4
visible: notifToast.currentNotif && notifToast.currentNotif.actions.length > 0
Repeater {
model: notifToast.currentNotif ? notifToast.currentNotif.actions : []
Rectangle {
required property var modelData
width: toastActionText.width + 12
height: toastActionText.height + 6
radius: 4
color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01
border.width: 1
border.color: Theme.base02
Text {
id: toastActionText
anchors.centerIn: parent
text: modelData.text
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 10
}
MouseArea {
id: toastActionMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { modelData.invoke(); notifToast.dismiss(); }
}
}
Connections {
target: bar.shellRoot
function onNotificationReceived() {
if (toastItem.isPrimary) {
toastItem.showToast(bar.shellRoot.latestNotification);
}
}
}
Text {
id: toastDismiss
anchors.right: parent.right
function showToast(notification) {
currentNotif = notification;
visible = true;
toastOpen = true;
_toastTimer.restart();
if (!mutedApps.includes(notification.appName)) {
notifSoundProc.running = true;
}
}
function dismiss() {
toastOpen = false;
_toastCloseDelay.start();
}
Timer {
id: _toastTimer
interval: 5000
onTriggered: toastItem.dismiss()
}
Timer {
id: _toastCloseDelay
interval: 230
onTriggered: { toastItem.visible = false; toastItem.toastOpen = false; }
}
HoverHandler {
onHoveredChanged: {
if (hovered) _toastTimer.stop();
else _toastTimer.restart();
}
}
// Left inverse corner ear
Item {
id: _toastLeftEar
anchors.right: _toastRect.left
anchors.top: parent.top
anchors.margins: 8
text: "\u{f0156}"
color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03
font.family: "FiraMono Nerd Font"
font.pixelSize: 13
MouseArea {
id: toastDismissMa
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0); ctx.lineTo(8, 8);
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, 8, 8, 0, -Math.PI / 2, true);
ctx.stroke();
}
}
}
// Right inverse corner ear
Item {
id: _toastRightEar
anchors.left: _toastRect.right
anchors.top: parent.top
width: 8
height: Math.min(8, _toastRect.height)
clip: true
visible: _toastRect.height >= 8
Canvas {
anchors.top: parent.top
width: 8; height: 8
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, 8, 8);
ctx.fillStyle = Theme.barBg;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(8, 0);
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(8, 8, 8, -Math.PI / 2, Math.PI, true);
ctx.stroke();
}
}
}
Rectangle {
id: _toastRect
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
width: 320
height: toastItem.toastOpen ? toastCol.height + 16 : 0
color: Theme.barBg
radius: 8
topLeftRadius: 0
topRightRadius: 0
clip: true
// Border outline (sides + bottom with rounded corners)
Canvas {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { notifToast.currentNotif.dismiss(); notifToast.dismiss(); }
onPaint: {
var ctx = getContext("2d");
var w = width, h = height, r = 8;
ctx.clearRect(0, 0, w, h);
if (h < 1) return;
ctx.strokeStyle = Theme.base03;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0.5, r);
ctx.lineTo(0.5, h - r);
ctx.arc(r + 0.5, h - r - 0.5, r, Math.PI, Math.PI / 2, true);
ctx.lineTo(w - r - 0.5, h - 0.5);
ctx.arc(w - r - 0.5, h - r - 0.5, r, Math.PI / 2, 0, true);
ctx.lineTo(w - 0.5, r);
ctx.stroke();
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
}
Behavior on height {
NumberAnimation { duration: 220; easing.type: Easing.OutCubic }
}
Column {
id: toastCol
anchors.left: parent.left
anchors.right: toastDismiss.left
anchors.top: parent.top
anchors.margins: 8
spacing: 2
Text {
width: parent.width
text: toastItem.currentNotif ? (toastItem.currentNotif.summary || toastItem.currentNotif.appName) : ""
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 12
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
text: toastItem.currentNotif ? (toastItem.currentNotif.body || "") : ""
color: Theme.base04
font.family: "FiraMono Nerd Font"
font.pixelSize: 11
elide: Text.ElideRight
maximumLineCount: 3
wrapMode: Text.Wrap
visible: text !== ""
}
Row {
spacing: 4
visible: toastItem.currentNotif && toastItem.currentNotif.actions.length > 0
Repeater {
model: toastItem.currentNotif ? toastItem.currentNotif.actions : []
Rectangle {
required property var modelData
width: toastActionText.width + 12
height: toastActionText.height + 6
radius: 4
color: toastActionMa.containsMouse ? Theme.base02 : Theme.base01
border.width: 1
border.color: Theme.base02
Text {
id: toastActionText
anchors.centerIn: parent
text: modelData.text
color: Theme.base05
font.family: "FiraMono Nerd Font"
font.pixelSize: 10
}
MouseArea {
id: toastActionMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { modelData.invoke(); toastItem.dismiss(); }
}
}
}
}
}
Text {
id: toastDismiss
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
text: "\u{f0156}"
color: toastDismissMa.containsMouse ? Theme.base05 : Theme.base03
font.family: "FiraMono Nerd Font"
font.pixelSize: 13
MouseArea {
id: toastDismissMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { toastItem.currentNotif.dismiss(); toastItem.dismiss(); }
}
}
}
}