summaryrefslogtreecommitdiffstats
path: root/quickshell/modules
diff options
context:
space:
mode:
Diffstat (limited to 'quickshell/modules')
-rw-r--r--quickshell/modules/bar/Bar.qml171
-rw-r--r--quickshell/modules/bar/Music.qml237
-rw-r--r--quickshell/modules/bar/QuickControl.qml246
-rw-r--r--quickshell/modules/bar/Time.qml144
-rw-r--r--quickshell/modules/bar/Workspaces.qml177
-rw-r--r--quickshell/modules/desktop/Wallpaper.qml180
-rw-r--r--quickshell/modules/notifications/NotificationCard.qml257
-rw-r--r--quickshell/modules/notifications/NotificationFocus.qml239
-rw-r--r--quickshell/modules/notifications/Notifications.qml125
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)
+ }
+ }
+ }
+ }
+ }
+}