diff options
Diffstat (limited to 'quickshell/modules')
| -rw-r--r-- | quickshell/modules/bar/Bar.qml | 171 | ||||
| -rw-r--r-- | quickshell/modules/bar/Music.qml | 237 | ||||
| -rw-r--r-- | quickshell/modules/bar/QuickControl.qml | 246 | ||||
| -rw-r--r-- | quickshell/modules/bar/Time.qml | 144 | ||||
| -rw-r--r-- | quickshell/modules/bar/Workspaces.qml | 177 | ||||
| -rw-r--r-- | quickshell/modules/desktop/Wallpaper.qml | 180 | ||||
| -rw-r--r-- | quickshell/modules/notifications/NotificationCard.qml | 257 | ||||
| -rw-r--r-- | quickshell/modules/notifications/NotificationFocus.qml | 239 | ||||
| -rw-r--r-- | quickshell/modules/notifications/Notifications.qml | 125 |
9 files changed, 1776 insertions, 0 deletions
diff --git a/quickshell/modules/bar/Bar.qml b/quickshell/modules/bar/Bar.qml new file mode 100644 index 0000000..e983faf --- /dev/null +++ b/quickshell/modules/bar/Bar.qml @@ -0,0 +1,171 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import "../../config.js" as Config + +PanelWindow { + id: bar + + required property var modelData + screen: modelData + + readonly property bool atBottom: Config.barPosition === "bottom" + readonly property real hiddenY: atBottom ? Config.barHeight : -Config.barHeight + + anchors { + top: !bar.atBottom + bottom: bar.atBottom + left: true + right: true + } + + implicitHeight: Config.barHeight + color: "transparent" + + Rectangle { + id: content + + anchors.fill: parent + color: Config.barBackground + opacity: 0 + y: bar.hiddenY + + RowLayout { + anchors { + left: parent.left + leftMargin: Config.barMargin + verticalCenter: parent.verticalCenter + } + + spacing: Config.workspaceSpacing + + Loader { + active: true + sourceComponent: Workspaces {} + } + } + + Rectangle { + anchors { + horizontalCenter: parent.horizontalCenter + verticalCenter: parent.verticalCenter + } + + height: Config.blockHeight + width: windowTitle.contentWidth + Config.blockPadding + radius: Config.blockRadius + color: "transparent" + + Text { + id: windowTitle + + anchors.centerIn: parent + + text: (niri.focusedWindow?.appId === "firefox" || + niri.focusedWindow?.appId === "firefox-bin") + ? "Mozilla Firefox" + : (niri.focusedWindow?.appId === "vesktop" || + niri.focusedWindow?.appId === "vesktop-bin") + ? "Vesktop" + : (niri.focusedWindow?.appId === "equibop" || + niri.focusedWindow?.appId === "equibop-bin") + ? "Equibop" + : (niri.focusedWindow?.title ?? "") + + + color: Config.textWindow + + font.family: Config.fontFamily + font.pixelSize: Config.fontSize + font.bold: true + + elide: Text.ElideRight + } + } + + RowLayout { + id: rightLayout + + anchors { + right: parent.right + rightMargin: Config.barMargin + verticalCenter: parent.verticalCenter + } + + spacing: Config.workspaceSpacing + + Loader { + active: true + sourceComponent: Music {} + } + + Loader { + active: true + sourceComponent: Time {} + } + + Loader { + active: true + sourceComponent: QuickControl {} + } + } + } + + ParallelAnimation { + id: showAnimation + + NumberAnimation { + target: content + property: "y" + from: bar.hiddenY + to: 0 + duration: Config.barAnimationDuration + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: content + property: "opacity" + from: 0 + to: 1 + duration: Config.barAnimationDuration + easing.type: Easing.OutCubic + } + } + + ParallelAnimation { + id: hideAnimation + + NumberAnimation { + target: content + property: "y" + from: 0 + to: bar.hiddenY + duration: Config.barAnimationDuration + easing.type: Easing.InCubic + } + + NumberAnimation { + target: content + property: "opacity" + from: 1 + to: 0 + duration: Config.barAnimationDuration + easing.type: Easing.InCubic + } + + onFinished: { + showAnimation.start() + } + } + + Component.onCompleted: { + showAnimation.start() + } + + function restart() { + showAnimation.stop() + hideAnimation.stop() + hideAnimation.start() + } +} diff --git a/quickshell/modules/bar/Music.qml b/quickshell/modules/bar/Music.qml new file mode 100644 index 0000000..50e1cf1 --- /dev/null +++ b/quickshell/modules/bar/Music.qml @@ -0,0 +1,237 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Mpris +import "../../config.js" as Config + +Rectangle { + id: musicBlock + + property var player: null + property var supportedPlayers: ["feishin", "fooyin"] + + implicitWidth: visible + ? Math.min(textMetrics.width + Config.blockPadding + 8, 400) + : 0 + + implicitHeight: Config.blockHeight + + radius: Config.blockRadius + + visible: player !== null && player.isPlaying + + function isSupported(p) { + var identity = (p.identity || "").toLowerCase() + var desktopEntry = (p.desktopEntry || "").toLowerCase() + + for (var i = 0; i < supportedPlayers.length; i++) { + var name = supportedPlayers[i] + + if ( + identity === name || + desktopEntry === name || + desktopEntry.endsWith("." + name) + ) + return true + } + + return false + } + + function findPlayer() { + var fallback = null + + for (var i = 0; i < Mpris.players.values.length; i++) { + var p = Mpris.players.values[i] + + console.log( + "MPRIS:", + "identity =", p.identity, + "desktopEntry =", p.desktopEntry + ) + + if (!isSupported(p)) + continue + + if (p.isPlaying) { + player = p + return + } + + if (!fallback) + fallback = p + } + + player = fallback + } + + function cleanTitle(title) { + if (!title) + return "" + + return title + .replace(/\s*[\(\[][^)\]]*\blive\b[^)\]]*[\)\]]/gi, "") + .replace(/\s*[-–—]\s*\blive\b.*$/i, "") + .trim() + } + + Component.onCompleted: { + findPlayer() + } + + Connections { + target: Mpris.players + + function onValuesChanged() { + findPlayer() + } + } + + gradient: Gradient { + GradientStop { + id: stop1 + position: 0.0 + color: Config.accentGreen + } + + GradientStop { + id: stop2 + position: 0.33 + color: Config.accentYellow + } + + GradientStop { + id: stop3 + position: 0.66 + color: Config.accentOrange + } + + GradientStop { + id: stop4 + position: 1.0 + color: Config.accentRed + } + } + + TextMetrics { + id: textMetrics + + font.family: Config.fontFamily + font.pixelSize: Config.fontSize + font.bold: true + + text: player + ? " " + player.trackArtist + " — " + cleanTitle(player.trackTitle) + : "" + } + + Text { + id: musicText + + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + } + + leftPadding: Config.blockPadding / 2 + rightPadding: Config.blockPadding / 2 + + text: player + ? " " + player.trackArtist + " — " + cleanTitle(player.trackTitle) + : "" + + color: Config.textDark + + font.family: Config.fontFamily + font.pixelSize: Config.fontSize + font.bold: true + + elide: Text.ElideRight + maximumLineCount: 1 + } + + MouseArea { + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (player && player.canRaise) + player.raise() + } + } + + SequentialAnimation { + running: true + loops: Animation.Infinite + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentGreen + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentYellow + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentOrange + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentRed + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + } + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentRed + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentGreen + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentYellow + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentOrange + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + } + } +} diff --git a/quickshell/modules/bar/QuickControl.qml b/quickshell/modules/bar/QuickControl.qml new file mode 100644 index 0000000..4b54d22 --- /dev/null +++ b/quickshell/modules/bar/QuickControl.qml @@ -0,0 +1,246 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.SystemTray +import "../../config.js" as Config + +Rectangle { + id: root + + property bool expanded: false + + implicitWidth: expanded + ? layout.implicitWidth + 24 + : dotsContainer.implicitWidth + 24 + + implicitHeight: Config.blockHeight + + radius: Config.blockRadius + + gradient: Gradient { + GradientStop { + id: stop1 + position: 0.0 + color: Config.accentGreen + } + + GradientStop { + id: stop2 + position: 0.33 + color: Config.accentYellow + } + + GradientStop { + id: stop3 + position: 0.66 + color: Config.accentOrange + } + + GradientStop { + id: stop4 + position: 1.0 + color: Config.accentRed + } + } + + Behavior on implicitWidth { + NumberAnimation { + duration: 250 + easing.type: Easing.OutCubic + } + } + + RowLayout { + id: layout + + anchors { + right: parent.right + verticalCenter: parent.verticalCenter + rightMargin: 12 + } + + spacing: 8 + + Repeater { + model: SystemTray.items + + delegate: Item { + id: trayItem + + required property var modelData + + implicitWidth: 18 + implicitHeight: 18 + + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + + opacity: root.expanded ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: 200 + easing.type: Easing.OutCubic + } + } + + visible: opacity > 0 + + QsMenuAnchor { + id: menuAnchor + + anchor.item: trayItem + menu: modelData.menu + } + + Image { + anchors.fill: parent + + source: modelData.icon + sourceSize.width: 18 + sourceSize.height: 18 + fillMode: Image.PreserveAspectFit + } + + MouseArea { + anchors.fill: parent + + enabled: root.expanded + + acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton + + cursorShape: Qt.PointingHandCursor + + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) { + if (modelData.hasMenu) + menuAnchor.open() + return + } + + if (mouse.button === Qt.MiddleButton) { + modelData.secondaryActivate() + return + } + + if (modelData.onlyMenu && modelData.hasMenu) + menuAnchor.open() + else + modelData.activate() + } + } + } + } + + Item { + id: dotsContainer + + implicitWidth: 24 + implicitHeight: Config.blockHeight + + Layout.preferredWidth: 24 + Layout.preferredHeight: Config.blockHeight + Layout.alignment: Qt.AlignVCenter + + Text { + id: dots + + anchors.centerIn: parent + + text: "•••" + + color: Config.textDark + + font.family: Config.fontFamily + font.pixelSize: Config.fontSize + font.bold: true + + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + + MouseArea { + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + root.expanded = !root.expanded + } + } + } + } + } + + SequentialAnimation { + running: true + loops: Animation.Infinite + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentGreen + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentYellow + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentOrange + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentRed + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + } + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentRed + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentGreen + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentYellow + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentOrange + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + } + } +} diff --git a/quickshell/modules/bar/Time.qml b/quickshell/modules/bar/Time.qml new file mode 100644 index 0000000..dfa3f0d --- /dev/null +++ b/quickshell/modules/bar/Time.qml @@ -0,0 +1,144 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import "../../config.js" as Config + +Rectangle { + id: clockBlock + + implicitWidth: timeText.contentWidth + Config.blockPadding + implicitHeight: Config.blockHeight + + radius: Config.blockRadius + + property bool alternateFormat: false + + gradient: Gradient { + GradientStop { + id: stop1 + position: 0.0 + color: Config.accentGreen + } + + GradientStop { + id: stop2 + position: 0.33 + color: Config.accentYellow + } + + GradientStop { + id: stop3 + position: 0.66 + color: Config.accentOrange + } + + GradientStop { + id: stop4 + position: 1.0 + color: Config.accentRed + } + } + + SystemClock { + id: clock + precision: SystemClock.Seconds + } + + Text { + id: timeText + + anchors.centerIn: parent + + text: alternateFormat + ? Qt.formatDateTime(clock.date, " dddd, dd MMMM yyyy") + : Qt.formatDateTime(clock.date, " hh:mm AP • ddd, dd/MM") + + color: Config.textDark + font.family: Config.fontFamily + font.pixelSize: Config.fontSize + font.bold: true + } + + MouseArea { + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + alternateFormat = !alternateFormat + } + } + + SequentialAnimation { + running: true + loops: Animation.Infinite + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentGreen + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentYellow + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentOrange + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentRed + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + } + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentRed + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentGreen + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentYellow + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentOrange + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + } + } +} diff --git a/quickshell/modules/bar/Workspaces.qml b/quickshell/modules/bar/Workspaces.qml new file mode 100644 index 0000000..c9c8104 --- /dev/null +++ b/quickshell/modules/bar/Workspaces.qml @@ -0,0 +1,177 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import "../../config.js" as Config + +Rectangle { + id: workspaceBlock + + implicitWidth: workspaceRow.width + 16 + implicitHeight: Config.blockHeight + + color: "transparent" + + RowLayout { + id: workspaceRow + + anchors.verticalCenter: parent.verticalCenter + spacing: Config.workspaceSpacing + + Repeater { + model: niri.workspaces + + delegate: Rectangle { + id: workspace + + visible: model.output === Config.barOutput && index < 11 + + implicitWidth: Config.workspaceWidth + implicitHeight: Config.blockHeight + + radius: Config.blockRadius + + color: model.isActive + ? "transparent" + : Config.workspaceBackground + + layer.enabled: true + + Rectangle { + anchors.fill: parent + radius: parent.radius + visible: model.isActive + + gradient: Gradient { + GradientStop { + id: stop1 + position: 0.0 + color: Config.accentGreen + } + + GradientStop { + id: stop2 + position: 0.33 + color: Config.accentYellow + } + + GradientStop { + id: stop3 + position: 0.66 + color: Config.accentOrange + } + + GradientStop { + id: stop4 + position: 1.0 + color: Config.accentRed + } + } + + SequentialAnimation { + running: true + loops: Animation.Infinite + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentGreen + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentYellow + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentOrange + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentRed + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + } + + ParallelAnimation { + ColorAnimation { + target: stop1 + property: "color" + from: Config.accentRed + to: Config.accentGreen + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop2 + property: "color" + from: Config.accentGreen + to: Config.accentYellow + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop3 + property: "color" + from: Config.accentYellow + to: Config.accentOrange + duration: Config.gradientAnimationDuration + } + + ColorAnimation { + target: stop4 + property: "color" + from: Config.accentOrange + to: Config.accentRed + duration: Config.gradientAnimationDuration + } + } + } + } + + Item { + anchors.fill: parent + + Text { + anchors.fill: parent + + text: index + + color: model.isActive + ? Config.textDark + : Config.textInactive + + font.family: Config.fontFamily + font.pixelSize: Config.fontSize + font.bold: true + + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + MouseArea { + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + niri.focusWorkspaceById(model.id) + } + } + } + } + } +} diff --git a/quickshell/modules/desktop/Wallpaper.qml b/quickshell/modules/desktop/Wallpaper.qml new file mode 100644 index 0000000..c956862 --- /dev/null +++ b/quickshell/modules/desktop/Wallpaper.qml @@ -0,0 +1,180 @@ +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import "../../config.js" as Config + +Variants { + model: Quickshell.screens + + PanelWindow { + id: win + + required property var modelData + + property var wallpaper: Config.wallpapers.find(function(w) { + return w.outputs.indexOf(modelData.name) !== -1 + }) + + property string currentPath: wallpaper ? wallpaper.path : "" + property string previousPath: "" + property real reveal: 1 + property bool started: false + property bool initialized: false + property int direction: 0 + + readonly property bool horizontal: direction < 2 + readonly property bool fromStart: direction % 2 === 0 + + readonly property int fillValue: wallpaper ? Image[wallpaper.fillMode] : Image.PreserveAspectCrop + + readonly property bool ready: initialized && currentPath !== "" && newImage.status === Image.Ready && (previousPath === "" || oldImage.status === Image.Ready) + + screen: modelData + visible: wallpaper !== undefined + color: "#000000" + + anchors { + top: true + bottom: true + left: true + right: true + } + + exclusionMode: ExclusionMode.Ignore + + WlrLayershell.layer: WlrLayer.Background + WlrLayershell.namespace: "wallpaper" + + FileView { + id: stateFile + + path: Quickshell.env("HOME") + "/.cache/qs-wallpaper-" + win.modelData.name + blockLoading: true + printErrors: false + } + + function pickDirection() { + const names = ["left", "right", "top", "bottom"] + const wanted = Config.wallpaperWipeDirection ?? "random" + const index = names.indexOf(wanted) + + if (index !== -1) + return index + + return Math.floor(Math.random() * 4) + } + + function begin() { + if (started || currentPath === "") + return + + started = true + + if (reveal < 1) + wipe.start() + else + finish() + } + + function finish() { + previousPath = currentPath + stateFile.setText(currentPath) + } + + onReadyChanged: { + if (ready) + begin() + } + + Component.onCompleted: { + let saved = "" + + try { + saved = stateFile.text().trim() + } catch (e) { + saved = "" + } + + previousPath = saved + direction = pickDirection() + reveal = (saved !== currentPath) ? 0 : 1 + initialized = true + + if (ready) + begin() + } + + NumberAnimation { + id: wipe + + target: win + property: "reveal" + from: 0 + to: 1 + duration: Config.wallpaperWipeDuration ?? 700 + easing.type: Easing.InOutCubic + + onFinished: win.finish() + } + + Item { + id: stack + + anchors.fill: parent + opacity: win.wallpaper ? win.wallpaper.opacity : 1.0 + + readonly property real clipWidth: win.horizontal ? (width * win.reveal) : width + readonly property real clipHeight: win.horizontal ? height : (height * win.reveal) + readonly property real clipX: (win.horizontal && !win.fromStart) ? (width - clipWidth) : 0 + readonly property real clipY: (!win.horizontal && !win.fromStart) ? (height - clipHeight) : 0 + + Image { + id: oldImage + + anchors.fill: parent + clip: true + + source: win.initialized ? win.previousPath : "" + fillMode: win.fillValue + asynchronous: win.wallpaper ? win.wallpaper.asynchronous : true + cache: win.wallpaper ? win.wallpaper.cache : true + } + + Item { + id: revealClip + + x: stack.clipX + y: stack.clipY + width: stack.clipWidth + height: stack.clipHeight + clip: true + + Image { + id: newImage + + x: 0 - stack.clipX + y: 0 - stack.clipY + width: stack.width + height: stack.height + clip: true + + source: win.initialized ? win.currentPath : "" + fillMode: win.fillValue + asynchronous: win.wallpaper ? win.wallpaper.asynchronous : true + cache: win.wallpaper ? win.wallpaper.cache : true + } + } + } + + MultiEffect { + anchors.fill: stack + + source: stack + + blurEnabled: win.wallpaper ? win.wallpaper.blur > 0 : false + blur: win.wallpaper ? win.wallpaper.blur : 0 + } + } +} diff --git a/quickshell/modules/notifications/NotificationCard.qml b/quickshell/modules/notifications/NotificationCard.qml new file mode 100644 index 0000000..983cbd7 --- /dev/null +++ b/quickshell/modules/notifications/NotificationCard.qml @@ -0,0 +1,257 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell.Services.Notifications +import "../../config.js" as Config + +Rectangle { + id: root + + required property var notification + + signal activated() + + property bool dismissing: false + + Layout.fillWidth: true + + implicitHeight: body.implicitHeight + 20 + + radius: Config.blockRadius + + color: Config.barBackground + + border.width: 1 + border.color: Config.notificationBorder + + x: dismissing ? Config.notificationSlideDistance : 0 + opacity: dismissing ? 0 : 1 + + Component.onCompleted: { + x = Config.notificationSlideDistance + opacity = 0 + + enterAnimation.start() + + if (notification.urgency !== NotificationUrgency.Critical) + dismissTimer.start() + } + + Timer { + id: dismissTimer + + interval: Config.notificationTimeout + repeat: false + + onTriggered: { + dismiss() + } + } + + function dismiss() { + if (dismissing) + return + + dismissTimer.stop() + dismissing = true + exitAnimation.start() + } + + SequentialAnimation { + id: enterAnimation + + ParallelAnimation { + NumberAnimation { + target: root + property: "x" + from: Config.notificationSlideDistance + to: 0 + duration: Config.notificationAnimationDuration + easing.type: Easing.OutCubic + } + + NumberAnimation { + target: root + property: "opacity" + from: 0 + to: 1 + duration: Config.notificationAnimationDuration + easing.type: Easing.OutCubic + } + } + } + + SequentialAnimation { + id: exitAnimation + + ParallelAnimation { + NumberAnimation { + target: root + property: "x" + to: Config.notificationSlideDistance + duration: Config.notificationAnimationDuration + easing.type: Easing.InCubic + } + + NumberAnimation { + target: root + property: "opacity" + to: 0 + duration: Config.notificationAnimationDuration + easing.type: Easing.InCubic + } + } + + ScriptAction { + script: root.notification.expire() + } + } + + ColumnLayout { + id: body + + anchors.fill: parent + anchors.margins: 10 + + spacing: 6 + + RowLayout { + Layout.fillWidth: true + + Image { + visible: root.notification.appIcon !== "" + + source: root.notification.appIcon + + sourceSize.width: 24 + sourceSize.height: 24 + + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + + fillMode: Image.PreserveAspectFit + asynchronous: true + } + + Text { + Layout.fillWidth: true + + text: root.notification.appName || "Notification" + + color: Config.textPrimary + + font.family: Config.fontFamily + font.pixelSize: 12 + font.bold: true + + elide: Text.ElideRight + } + + Text { + text: "×" + + color: "#6c7086" + + font.family: Config.fontFamily + font.pixelSize: 18 + font.bold: true + + MouseArea { + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + root.dismiss() + } + } + } + } + + Text { + Layout.fillWidth: true + + text: root.notification.summary + + color: Config.textPrimary + + font.family: Config.fontFamily + font.pixelSize: Config.fontSize + font.bold: true + + wrapMode: Text.Wrap + } + + Text { + visible: root.notification.body !== "" + + Layout.fillWidth: true + + text: root.notification.body + + color: Config.textSecondary + + font.family: Config.fontFamily + font.pixelSize: 12 + + wrapMode: Text.Wrap + + maximumLineCount: 4 + elide: Text.ElideRight + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 3 + + radius: 2 + + gradient: Gradient { + GradientStop { + position: 0.0 + color: Config.accentGreen + } + + GradientStop { + position: 0.33 + color: Config.accentYellow + } + + GradientStop { + position: 0.66 + color: Config.accentOrange + } + + GradientStop { + position: 1.0 + color: Config.accentRed + } + } + } + } + + MouseArea { + anchors.fill: parent + + cursorShape: Qt.PointingHandCursor + + onClicked: { + root.activated() + } + } + + MouseArea { + anchors.right: parent.right + anchors.top: parent.top + + width: 36 + height: 36 + + z: 10 + + cursorShape: Qt.PointingHandCursor + + onClicked: { + root.dismiss() + } + } +} diff --git a/quickshell/modules/notifications/NotificationFocus.qml b/quickshell/modules/notifications/NotificationFocus.qml new file mode 100644 index 0000000..ce59b2a --- /dev/null +++ b/quickshell/modules/notifications/NotificationFocus.qml @@ -0,0 +1,239 @@ +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + + property string pendingApp: "" + + function normalize(value) { + if (!value) + return "" + + var result = String(value).toLowerCase().trim() + + result = result.replace(/^org\./, "") + result = result.replace(/^com\./, "") + result = result.replace(/^net\./, "") + result = result.replace(/^app\./, "") + + result = result.replace(/\.desktop$/, "") + result = result.replace(/\.desktop$/i, "") + + result = result.replace(/[^a-z0-9]+/g, "") + + return result + } + + function candidates(value) { + var result = [] + var raw = String(value || "").toLowerCase().trim() + + function add(candidate) { + if (!candidate) + return + + candidate = normalize(candidate) + + if (candidate && result.indexOf(candidate) === -1) + result.push(candidate) + } + + add(raw) + + if (raw.indexOf(".") !== -1) { + var parts = raw.split(".") + + add(parts[parts.length - 1]) + + for (var i = 0; i < parts.length; i++) + add(parts[i]) + } + + if (raw.indexOf("/") !== -1) + add(raw.split("/").pop()) + + if (raw.indexOf("-") !== -1) { + var dashParts = raw.split("-") + + for (var j = 0; j < dashParts.length; j++) + add(dashParts[j]) + } + + if (raw.indexOf("_") !== -1) { + var underscoreParts = raw.split("_") + + for (var k = 0; k < underscoreParts.length; k++) + add(underscoreParts[k]) + } + + return result + } + + function matchScore(notificationApp, windowAppId, windowTitle) { + var notificationCandidates = candidates(notificationApp) + var windowCandidates = candidates(windowAppId) + + var best = 0 + + for (var i = 0; i < notificationCandidates.length; i++) { + var notificationCandidate = notificationCandidates[i] + + for (var j = 0; j < windowCandidates.length; j++) { + var windowCandidate = windowCandidates[j] + + if (notificationCandidate === windowCandidate) + best = Math.max(best, 100) + + else if ( + notificationCandidate.length >= 4 && + windowCandidate.length >= 4 && + ( + notificationCandidate.indexOf(windowCandidate) !== -1 || + windowCandidate.indexOf(notificationCandidate) !== -1 + ) + ) { + best = Math.max(best, 60) + } + } + } + + var titleCandidates = candidates(windowTitle) + + for (var n = 0; n < notificationCandidates.length; n++) { + for (var t = 0; t < titleCandidates.length; t++) { + if ( + notificationCandidates[n].length >= 4 && + notificationCandidates[n] === titleCandidates[t] + ) { + best = Math.max(best, 30) + } + } + } + + return best + } + + Process { + id: windowsProcess + + command: ["niri", "msg", "-j", "windows"] + + stdout: StdioCollector { + id: windowsOutput + + onStreamFinished: { + var windows + + try { + windows = JSON.parse(this.text) + } catch (error) { + console.error( + "Failed to parse Niri windows:", + error + ) + + root.pendingApp = "" + return + } + + var notificationApp = root.pendingApp + root.pendingApp = "" + + if (!notificationApp) { + console.warn("Notification focus: empty app name") + return + } + + if (!Array.isArray(windows)) { + console.error( + "Niri windows response is not an array" + ) + return + } + + var bestWindow = null + var bestScore = 0 + + for (var i = 0; i < windows.length; i++) { + var window = windows[i] + + if (!window || window.id === undefined) + continue + + var appId = window.app_id || "" + var title = window.title || "" + + var score = matchScore( + notificationApp, + appId, + title + ) + + if (score > bestScore) { + bestScore = score + bestWindow = window + } + } + + if (!bestWindow) { + console.log( + "No matching Niri window found:", + notificationApp + ) + return + } + + console.log( + "FOCUSING WINDOW:", + bestWindow.id, + "notification app:", + notificationApp, + "app_id:", + bestWindow.app_id, + "title:", + bestWindow.title, + "score:", + bestScore + ) + + focusProcess.exec([ + "niri", + "msg", + "action", + "focus-window", + "--id", + String(bestWindow.id) + ]) + } + } + } + + Process { + id: focusProcess + } + + function focusSource(notification) { + if (!notification) + return + + var app = notification.appName || "" + + if (!app) { + console.warn( + "Notification has no application name" + ) + return + } + + root.pendingApp = app + + windowsProcess.exec([ + "niri", + "msg", + "-j", + "windows" + ]) + } +} diff --git a/quickshell/modules/notifications/Notifications.qml b/quickshell/modules/notifications/Notifications.qml new file mode 100644 index 0000000..2965b90 --- /dev/null +++ b/quickshell/modules/notifications/Notifications.qml @@ -0,0 +1,125 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Notifications +import "../../config.js" as Config +import "./" + +Item { + id: root + + NotificationServer { + id: server + + bodySupported: true + imageSupported: true + actionsSupported: true + actionIconsSupported: true + bodyMarkupSupported: true + + onNotification: function(notification) { + console.log("NOTIFICATION") + console.log(" app:", notification.appName) + console.log(" summary:", notification.summary) + console.log(" body:", notification.body) + console.log(" actions:", notification.actions.length) + + for (var i = 0; i < notification.actions.length; i++) { + console.log( + " action:", + notification.actions[i].identifier, + notification.actions[i].text + ) + } + + notification.tracked = true + } + } + + NotificationFocus { + id: focus + } + + function activateNotification(notification) { + if (!notification) + return + + console.log( + "ACTIVATE:", + notification.appName, + notification.summary + ) + + focus.focusSource(notification) + + for (var i = 0; i < notification.actions.length; i++) { + var action = notification.actions[i] + + if (action.identifier === "default") { + console.log( + "Invoking default action:", + action.text + ) + + action.invoke() + return + } + } + + console.log("No default action available") + notification.dismiss() + } + + PanelWindow { + id: window + + screen: Quickshell.screens.find(function(screen) { + return screen.name === Config.barOutput + }) + + anchors { + top: true + right: true + } + + margins { + top: Config.notificationMarginTop + right: Config.notificationMarginRight + } + + exclusionMode: ExclusionMode.Ignore + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "notifications" + + color: "transparent" + + implicitWidth: Config.notificationWidth + implicitHeight: list.implicitHeight + + ColumnLayout { + id: list + + anchors.top: parent.top + anchors.right: parent.right + + width: Config.notificationWidth + spacing: Config.notificationSpacing + + Repeater { + model: server.trackedNotifications + + delegate: NotificationCard { + required property var modelData + + notification: modelData + + onActivated: { + root.activateNotification(modelData) + } + } + } + } + } +} |
