From 9762553b38901f2270feadaded94b4bc3e4b7e05 Mon Sep 17 00:00:00 2001 From: Kris Date: Sun, 1 Mar 2026 10:37:07 +0200 Subject: [PATCH] upd --- Shell/Healthbar.qml | 123 +++++++ Shell/Notifications/NotificationCard.qml | 392 ++++++++++++++++++++++ Shell/Notifications/NotificationLayer.qml | 113 +++++++ Shell/Notifications/NotificationModel.qml | 94 ++++++ Shell/Topbar.qml | 80 +++++ shell.qml | 2 + 6 files changed, 804 insertions(+) create mode 100644 Shell/Notifications/NotificationCard.qml create mode 100644 Shell/Notifications/NotificationLayer.qml create mode 100644 Shell/Notifications/NotificationModel.qml diff --git a/Shell/Healthbar.qml b/Shell/Healthbar.qml index e5c4c51..1076056 100644 --- a/Shell/Healthbar.qml +++ b/Shell/Healthbar.qml @@ -1,9 +1,13 @@ +import QtQuick import Quickshell import Quickshell.Wayland +import Quickshell.Services.Mpris import "Healthbar" PanelWindow { + id: healthbarWindow + anchors { left: true right: true @@ -25,5 +29,124 @@ PanelWindow { implicitHeight: 137 color: "#000000" + property string currentSongText: "♪ NO SONG" + + function formatSong(player) { + if (!player) + return ""; + + const title = String(player.trackTitle || ""); + if (title.length === 0) + return ""; + + const artist = String(player.trackArtist || ""); + const album = String(player.trackAlbum || ""); + const core = artist.length > 0 ? (artist + " - " + title) : title; + return album.length > 0 ? (core + " (" + album + ")") : core; + } + + function updateSongText() { + let playingSong = ""; + let fallbackSong = ""; + + for (let i = 0; i < mprisRepeater.count; i++) { + const item = mprisRepeater.itemAt(i); + if (!item || !item.player) + continue; + + const candidate = formatSong(item.player); + if (candidate.length === 0) + continue; + + if (fallbackSong.length === 0) + fallbackSong = candidate; + + if (item.player.isPlaying) { + playingSong = candidate; + break; + } + } + + const chosen = playingSong.length > 0 ? playingSong : fallbackSong; + currentSongText = chosen.length > 0 ? ("♪ " + chosen) : "♪ NO SONG"; + } + + Repeater { + id: mprisRepeater + model: Mpris.players + + delegate: Item { + required property var modelData + property var player: modelData + visible: false + + Connections { + target: player + function onTrackChanged() { + healthbarWindow.updateSongText(); + } + function onPostTrackChanged() { + healthbarWindow.updateSongText(); + } + function onPlaybackStateChanged() { + healthbarWindow.updateSongText(); + } + } + } + } + + Connections { + target: Mpris.players + ignoreUnknownSignals: true + function onCountChanged() { + healthbarWindow.updateSongText(); + } + function onModelReset() { + healthbarWindow.updateSongText(); + } + function onRowsInserted() { + healthbarWindow.updateSongText(); + } + function onRowsRemoved() { + healthbarWindow.updateSongText(); + } + } + Healthbar {} + + Text { + x: parent.width - width - 24 + 1 + y: parent.height - height - 8 + 1 + width: Math.floor(parent.width * 0.45) + elide: Text.ElideRight + color: "#04047c" + text: healthbarWindow.currentSongText + font.family: "8bitoperator JVE" + font.pixelSize: 28 + font.letterSpacing: 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + horizontalAlignment: Text.AlignRight + } + + Text { + x: parent.width - width - 24 + y: parent.height - height - 8 + width: Math.floor(parent.width * 0.45) + elide: Text.ElideRight + color: "#ffffff" + text: healthbarWindow.currentSongText + font.family: "8bitoperator JVE" + font.pixelSize: 28 + font.letterSpacing: 1 + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + horizontalAlignment: Text.AlignRight + } + + Component.onCompleted: updateSongText() } diff --git a/Shell/Notifications/NotificationCard.qml b/Shell/Notifications/NotificationCard.qml new file mode 100644 index 0000000..78a2a1f --- /dev/null +++ b/Shell/Notifications/NotificationCard.qml @@ -0,0 +1,392 @@ +import QtQuick +import Qt5Compat.GraphicalEffects +import Quickshell +import Quickshell.Widgets + +Item { + id: root + + property int notificationId: -1 + property string notifTitle: "" + property string notifBody: "" + property string notifUrgency: "Normal" + property string notifAppName: "" + property string notifAppIcon: "" + property string notifImage: "" + property int notifTimeoutMs: 5000 + property bool notifCritical: false + property var notifHints: ({}) + + signal closeFinished(int notificationId, string reason) + + property bool highlighted: hoverHandler.hovered + property bool closing: false + property string closeReason: "" + property real borderAlpha: 1 + property real textAlpha: 1 + property real collapseFactor: 1 + + readonly property color normalInk: "#ffffff" + readonly property color highlightInk: "#ffc90e" + readonly property color activeInk: highlighted ? highlightInk : normalInk + readonly property int padding: 14 + readonly property int iconBox: 28 + readonly property int appIconSize: 24 + readonly property string appIconSource: { + if (notifImage && notifImage.length > 0) + return notifImage; + + if (!notifAppIcon || notifAppIcon.length === 0) + return ""; + + if (notifAppIcon.indexOf("/") >= 0 || notifAppIcon.indexOf("file://") === 0) + return notifAppIcon; + + return Quickshell.iconPath(notifAppIcon); + } + + function hintString(name) { + if (!notifHints || typeof notifHints !== "object") + return ""; + const value = notifHints[name]; + return value === undefined || value === null ? "" : String(value); + } + + function textBlob() { + return (notifAppName + " " + notifTitle + " " + notifBody + " " + hintString("category")).toLowerCase(); + } + + function parseVolumePercent() { + const source = (notifTitle + " " + notifBody).toUpperCase(); + const match = source.match(/VOLUME\s*(\d{1,3})%/); + if (!match || match.length < 2) + return -1; + + const parsed = Number(match[1]); + if (!Number.isFinite(parsed)) + return -1; + + return Math.max(0, Math.min(100, parsed)); + } + + readonly property int volumePercent: parseVolumePercent() + readonly property bool isVolumeLayout: volumePercent >= 0 + + function soulColor() { + const full = textBlob(); + const urgencyText = String(notifUrgency || "").toLowerCase(); + + if (urgencyText === "critical") + return "#fff27a"; + + if (full.indexOf("network") >= 0 || full.indexOf("wifi") >= 0 || full.indexOf("ethernet") >= 0 || full.indexOf("bluetooth") >= 0) + return "#4ca4ff"; + + if (full.indexOf("success") >= 0 || full.indexOf("completed") >= 0 || full.indexOf("saved") >= 0 || full.indexOf("done") >= 0) + return "#47d66b"; + + return "#ff2a2a"; + } + + readonly property color soulInk: soulColor() + + function beginClose(reason) { + if (closing) + return; + + closing = true; + closeReason = reason; + timeoutTimer.stop(); + + if (reason === "click") { + heartFlash.restart(); + clickClose.start(); + } else { + timeoutClose.start(); + } + } + + implicitWidth: 420 + implicitHeight: Math.max(1, contentColumn.implicitHeight * collapseFactor + padding * 2) + width: implicitWidth + height: implicitHeight + transformOrigin: Item.TopRight + + SequentialAnimation { + id: entryAnimation + running: true + + PropertyAction { + target: root + property: "opacity" + value: 0 + } + PropertyAction { + target: root + property: "scale" + value: 0.95 + } + + ParallelAnimation { + NumberAnimation { + target: root + property: "opacity" + to: 0.45 + duration: 55 + } + NumberAnimation { + target: root + property: "scale" + to: 0.97 + duration: 55 + } + } + + ParallelAnimation { + NumberAnimation { + target: root + property: "opacity" + to: 0.75 + duration: 55 + } + NumberAnimation { + target: root + property: "scale" + to: 0.99 + duration: 55 + } + } + + ParallelAnimation { + NumberAnimation { + target: root + property: "opacity" + to: 1 + duration: 70 + } + NumberAnimation { + target: root + property: "scale" + to: 1 + duration: 70 + } + } + } + + SequentialAnimation { + id: heartFlash + NumberAnimation { + target: soulFlashOverlay + property: "opacity" + to: 1 + duration: 35 + } + NumberAnimation { + target: soulFlashOverlay + property: "opacity" + to: 0 + duration: 75 + } + } + + SequentialAnimation { + id: clickClose + ParallelAnimation { + NumberAnimation { + target: root + property: "y" + to: -8 + duration: 160 + easing.type: Easing.InCubic + } + NumberAnimation { + target: root + property: "opacity" + to: 0 + duration: 160 + } + } + ScriptAction { + script: root.closeFinished(root.notificationId, "click") + } + } + + SequentialAnimation { + id: timeoutClose + NumberAnimation { + target: root + property: "borderAlpha" + to: 0.35 + duration: 90 + } + NumberAnimation { + target: root + property: "textAlpha" + to: 0 + duration: 110 + } + ParallelAnimation { + NumberAnimation { + target: root + property: "collapseFactor" + to: 0 + duration: 150 + easing.type: Easing.InCubic + } + NumberAnimation { + target: root + property: "opacity" + to: 0 + duration: 150 + } + } + ScriptAction { + script: root.closeFinished(root.notificationId, "timeout") + } + } + + Timer { + id: timeoutTimer + interval: root.notifTimeoutMs + repeat: false + running: !root.notifCritical && root.notifTimeoutMs > 0 + onTriggered: root.beginClose("timeout") + } + + Rectangle { + anchors.fill: parent + color: "#000000" + border.width: 3 + border.color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.borderAlpha) + radius: 0 + antialiasing: false + } + + Item { + id: soulContainer + x: { + if (!root.isVolumeLayout) + return root.padding; + + const left = root.padding; + const right = Math.max(left, root.width - root.padding - root.iconBox); + return left + (right - left) * (root.volumePercent / 100); + } + y: root.padding + 2 + width: root.iconBox + height: root.iconBox + scale: 1 + + Behavior on x { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + + IconImage { + visible: root.appIconSource.length > 0 + anchors.centerIn: parent + implicitSize: root.appIconSize + source: root.appIconSource + asynchronous: true + } + + Item { + visible: root.appIconSource.length === 0 + anchors.fill: parent + + Image { + id: soulImage + anchors.fill: parent + source: "../Topbar/topbar/soul_small.png" + smooth: false + antialiasing: false + } + + ColorOverlay { + anchors.fill: soulImage + source: soulImage + color: root.soulInk + } + } + + Rectangle { + id: soulFlashOverlay + anchors.fill: parent + color: "#ffffff" + opacity: 0 + radius: 0 + antialiasing: false + } + } + + Column { + id: contentColumn + x: root.padding + root.iconBox + 10 + y: root.padding - 1 + width: root.width - x - root.padding + spacing: 2 + + Text { + visible: !root.isVolumeLayout + text: String(root.notifTitle || "") + color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha) + font.family: "8bitoperator JVE" + font.pixelSize: 28 + font.letterSpacing: 1 + wrapMode: Text.NoWrap + elide: Text.ElideRight + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + width: parent.width + } + + Text { + visible: !root.isVolumeLayout + text: String(root.notifBody || "") + color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha) + font.family: "8bitoperator JVE" + font.pixelSize: 24 + font.letterSpacing: 1 + wrapMode: Text.Wrap + maximumLineCount: 4 + elide: Text.ElideRight + textFormat: Text.PlainText + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + width: parent.width + } + + Text { + visible: root.isVolumeLayout + text: "VOLUME " + root.volumePercent + "%" + color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha) + font.family: "8bitoperator JVE" + font.pixelSize: 30 + font.letterSpacing: 1 + wrapMode: Text.NoWrap + elide: Text.ElideRight + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + smooth: false + antialiasing: false + width: parent.width + } + + } + + HoverHandler { + id: hoverHandler + } + + TapHandler { + acceptedButtons: Qt.LeftButton + enabled: root.highlighted && !root.closing + onTapped: root.beginClose("click") + } +} diff --git a/Shell/Notifications/NotificationLayer.qml b/Shell/Notifications/NotificationLayer.qml new file mode 100644 index 0000000..6168f57 --- /dev/null +++ b/Shell/Notifications/NotificationLayer.qml @@ -0,0 +1,113 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import ".." + +PanelWindow { + id: notificationLayer + + anchors { + top: true + right: true + } + + margins { + top: 24 + right: 24 + } + + // Top layer keeps notifications above normal windows while still usually + // below fullscreen overlays. Overlay would be too aggressive here. + WlrLayershell.layer: WlrLayer.Top + WlrLayershell.focusable: false + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + WlrLayershell.namespace: "deltarune-quickshell-notifications" + + exclusionMode: ExclusionMode.Ignore + aboveWindows: true + focusable: false + visible: true + color: "#00000000" + + property int stackWidth: 420 + property int menuReservedHeight: 182 + + readonly property int screenHeight: screen ? screen.height : 1080 + readonly property bool topbarOpen: ShellStateManager.shellOpen + readonly property int stackOffsetY: topbarOpen ? menuReservedHeight + 24 : 0 + readonly property int maxStackHeight: Math.max(120, screenHeight - 24 - stackOffsetY - 24) + + implicitWidth: stackWidth + implicitHeight: stackOffsetY + notificationViewport.height + + // Input handling decision: the layer itself never grabs focus and is only + // as large as the stack. This keeps the container effectively click-through + // outside notification bounds and avoids any global pointer/keyboard grabs. + mask: Region { + item: notificationViewport + } + + NotificationModel { + id: notificationModel + } + + Flickable { + id: notificationViewport + x: 0 + y: notificationLayer.stackOffsetY + width: notificationLayer.stackWidth + height: Math.min(notificationColumn.implicitHeight, notificationLayer.maxStackHeight) + clip: true + boundsBehavior: Flickable.StopAtBounds + contentWidth: width + contentHeight: notificationColumn.implicitHeight + interactive: contentHeight > height + flickableDirection: Flickable.VerticalFlick + + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: function (event) { + if (!notificationViewport.interactive) + return; + const step = event.angleDelta.y / 120 * 60; + notificationViewport.contentY = Math.max(0, Math.min(notificationViewport.contentHeight - notificationViewport.height, notificationViewport.contentY - step)); + event.accepted = true; + } + } + + ScrollBar.vertical: ScrollBar { + policy: notificationViewport.interactive ? ScrollBar.AlwaysOn : ScrollBar.AlwaysOff + width: 6 + } + + Column { + id: notificationColumn + width: notificationViewport.width + spacing: 0 + + Repeater { + model: notificationModel.notifications + + NotificationCard { + notificationId: rowNotificationId + notifTitle: rowTitle + notifBody: rowBody + notifUrgency: rowUrgency + notifAppName: rowAppName + notifAppIcon: rowAppIcon + notifImage: rowImage + notifTimeoutMs: rowTimeoutMs + notifCritical: rowCritical + notifHints: rowHints + + width: notificationLayer.stackWidth + + onCloseFinished: function (closedNotificationId, reason) { + notificationModel.closeById(closedNotificationId, reason); + } + } + } + } + } +} diff --git a/Shell/Notifications/NotificationModel.qml b/Shell/Notifications/NotificationModel.qml new file mode 100644 index 0000000..6184c81 --- /dev/null +++ b/Shell/Notifications/NotificationModel.qml @@ -0,0 +1,94 @@ +import QtQuick +import Quickshell.Services.Notifications + +QtObject { + id: root + + property ListModel notifications: ListModel {} + + function indexOfId(notificationId) { + for (let i = 0; i < notifications.count; i++) { + const row = notifications.get(i); + if (row.rowNotificationId === notificationId) + return i; + } + return -1; + } + + function timeoutMsFor(notificationObject) { + const isCritical = notificationObject.urgency === NotificationUrgency.Critical; + if (isCritical) + return -1; + + const rawSeconds = Number(notificationObject.expireTimeout); + if (!Number.isFinite(rawSeconds) || rawSeconds <= 0) + return 5000; + + return Math.max(1000, Math.round(rawSeconds * 1000)); + } + + function addNotification(notificationObject) { + if (notificationObject.lastGeneration) { + notificationObject.dismiss(); + return; + } + + const existingIndex = indexOfId(notificationObject.id); + if (existingIndex >= 0) + notifications.remove(existingIndex); + + notificationObject.tracked = true; + + const idCopy = notificationObject.id; + notificationObject.closed.connect(function () { + const rowIndex = indexOfId(idCopy); + if (rowIndex >= 0) + notifications.remove(rowIndex); + }); + + notifications.append({ + rowNotificationId: notificationObject.id, + rowTitle: String(notificationObject.summary || ""), + rowBody: String(notificationObject.body || ""), + rowUrgency: NotificationUrgency.toString(notificationObject.urgency), + rowAppName: String(notificationObject.appName || ""), + rowAppIcon: String(notificationObject.appIcon || ""), + rowImage: String(notificationObject.image || ""), + rowTimeoutMs: timeoutMsFor(notificationObject), + rowCritical: notificationObject.urgency === NotificationUrgency.Critical, + rowHints: notificationObject.hints || ({}), + rowObject: notificationObject + }); + } + + function closeById(notificationId, reason) { + const rowIndex = indexOfId(notificationId); + if (rowIndex < 0) + return; + + const row = notifications.get(rowIndex); + const notifObject = row.rowObject; + + if (notifObject) { + if (reason === "timeout") + notifObject.expire(); + else + notifObject.dismiss(); + } + + notifications.remove(rowIndex); + } + + property NotificationServer server: NotificationServer { + keepOnReload: false + bodySupported: true + bodyMarkupSupported: false + bodyHyperlinksSupported: false + + // NotificationServer is Quickshell's DBus implementation for + // org.freedesktop.Notifications, so this is the notification source. + onNotification: function (notificationObject) { + root.addNotification(notificationObject); + } + } +} diff --git a/Shell/Topbar.qml b/Shell/Topbar.qml index b184ed5..6f6d4b3 100644 --- a/Shell/Topbar.qml +++ b/Shell/Topbar.qml @@ -1,9 +1,14 @@ +import QtQuick import Quickshell import Quickshell.Wayland +import Quickshell.Hyprland +import Quickshell.Services.Pipewire import "Topbar" PanelWindow { + id: topbarWindow + anchors { top: true left: true @@ -25,7 +30,82 @@ PanelWindow { implicitHeight: 182 color: "#000000" + readonly property var focusedWorkspace: Hyprland.focusedWorkspace + readonly property string workspaceLabel: { + if (!focusedWorkspace) + return "WS --"; + + if (focusedWorkspace.id >= 0) + return "WS " + focusedWorkspace.id; + + return "WS " + String(focusedWorkspace.name || "--"); + } + + readonly property var defaultSink: Pipewire.defaultAudioSink + readonly property string volumeLabel: { + if (!defaultSink || !defaultSink.audio) + return "VOL --"; + + if (defaultSink.audio.muted) + return "VOL MUTE"; + + return "VOL " + Math.round(defaultSink.audio.volume * 100) + "%"; + } + + PwObjectTracker { + objects: [Pipewire.defaultAudioSink] + } + + SystemClock { + id: clock + precision: SystemClock.Minutes + } + Topbar { manager: ShellStateManager } + + Text { + anchors.left: parent.left + anchors.leftMargin: 18 + anchors.top: parent.top + anchors.topMargin: 10 + color: "#ffffff" + text: topbarWindow.workspaceLabel + font.pixelSize: 36 + antialiasing: false + font.family: "8bitoperator JVE" + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + } + + Column { + spacing: 2 + anchors.right: parent.right + anchors.rightMargin: 18 + anchors.top: parent.top + anchors.topMargin: 10 + + Text { + anchors.right: parent.right + color: "#ffffff" + text: topbarWindow.volumeLabel + font.pixelSize: 36 + antialiasing: false + font.family: "8bitoperator JVE" + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + } + + Text { + anchors.right: parent.right + color: "#ffffff" + text: Qt.formatTime(clock.date, "hh:mm") + font.pixelSize: 36 + antialiasing: false + font.family: "8bitoperator JVE" + renderType: Text.NativeRendering + font.hintingPreference: Font.PreferNoHinting + } + } } diff --git a/shell.qml b/shell.qml index 1e5ac66..8749c8b 100644 --- a/shell.qml +++ b/shell.qml @@ -4,6 +4,7 @@ import Quickshell.Hyprland import QtQuick import "Shell" import "Shell/Overlays" +import "Shell/Notifications" ShellRoot { id: baseShell @@ -11,6 +12,7 @@ ShellRoot { property bool isOpen: ShellStateManager.shellOpen Overlay {} + NotificationLayer {} Topbar {} Healthbar {}