From 98560bbbefc683d227b5d9af7ede798ebf4e647c Mon Sep 17 00:00:00 2001 From: rope Date: Wed, 27 May 2026 16:18:38 +0100 Subject: [PATCH] quickshell: move notification toast into bar surface Co-Authored-By: Claude Opus 4.6 --- settings/hyprland.nix | 506 +++++++++++++++++++++--------------------- 1 file changed, 254 insertions(+), 252 deletions(-) diff --git a/settings/hyprland.nix b/settings/hyprland.nix index c4b890e..b879484 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -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(); } + } } } }