This commit is contained in:
2026-03-01 10:37:07 +02:00
parent e5257d9e42
commit 9762553b38
6 changed files with 804 additions and 0 deletions

View File

@@ -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"
Healthbar {}
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()
}

View File

@@ -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")
}
}

View File

@@ -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);
}
}
}
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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 {}