upd
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
|
import Quickshell.Services.Mpris
|
||||||
|
|
||||||
import "Healthbar"
|
import "Healthbar"
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
|
id: healthbarWindow
|
||||||
|
|
||||||
anchors {
|
anchors {
|
||||||
left: true
|
left: true
|
||||||
right: true
|
right: true
|
||||||
@@ -25,5 +29,124 @@ PanelWindow {
|
|||||||
implicitHeight: 137
|
implicitHeight: 137
|
||||||
color: "#000000"
|
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 {}
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
392
Shell/Notifications/NotificationCard.qml
Normal file
392
Shell/Notifications/NotificationCard.qml
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
113
Shell/Notifications/NotificationLayer.qml
Normal file
113
Shell/Notifications/NotificationLayer.qml
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Shell/Notifications/NotificationModel.qml
Normal file
94
Shell/Notifications/NotificationModel.qml
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Services.Pipewire
|
||||||
|
|
||||||
import "Topbar"
|
import "Topbar"
|
||||||
|
|
||||||
PanelWindow {
|
PanelWindow {
|
||||||
|
id: topbarWindow
|
||||||
|
|
||||||
anchors {
|
anchors {
|
||||||
top: true
|
top: true
|
||||||
left: true
|
left: true
|
||||||
@@ -25,7 +30,82 @@ PanelWindow {
|
|||||||
implicitHeight: 182
|
implicitHeight: 182
|
||||||
color: "#000000"
|
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 {
|
Topbar {
|
||||||
manager: ShellStateManager
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Quickshell.Hyprland
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import "Shell"
|
import "Shell"
|
||||||
import "Shell/Overlays"
|
import "Shell/Overlays"
|
||||||
|
import "Shell/Notifications"
|
||||||
|
|
||||||
ShellRoot {
|
ShellRoot {
|
||||||
id: baseShell
|
id: baseShell
|
||||||
@@ -11,6 +12,7 @@ ShellRoot {
|
|||||||
property bool isOpen: ShellStateManager.shellOpen
|
property bool isOpen: ShellStateManager.shellOpen
|
||||||
|
|
||||||
Overlay {}
|
Overlay {}
|
||||||
|
NotificationLayer {}
|
||||||
|
|
||||||
Topbar {}
|
Topbar {}
|
||||||
Healthbar {}
|
Healthbar {}
|
||||||
|
|||||||
Reference in New Issue
Block a user