aboutsummaryrefslogtreecommitdiff
path: root/modules/system/quickshell
diff options
context:
space:
mode:
Diffstat (limited to 'modules/system/quickshell')
-rw-r--r--modules/system/quickshell/Background.qml21
-rw-r--r--modules/system/quickshell/Bar.qml108
-rw-r--r--modules/system/quickshell/Bluetooth.qml352
-rw-r--r--modules/system/quickshell/BrightnessService.qml36
-rw-r--r--modules/system/quickshell/ConnectivityBox.qml121
-rw-r--r--modules/system/quickshell/ControlCenter.qml141
-rw-r--r--modules/system/quickshell/ControlTile.qml50
-rw-r--r--modules/system/quickshell/CustomCheckBox.qml44
-rw-r--r--modules/system/quickshell/GlobalState.qml23
-rw-r--r--modules/system/quickshell/IconCircle.qml38
-rw-r--r--modules/system/quickshell/Launcher.qml228
-rw-r--r--modules/system/quickshell/LockContext.qml58
-rw-r--r--modules/system/quickshell/LockSurface.qml197
-rw-r--r--modules/system/quickshell/Media.qml119
-rw-r--r--modules/system/quickshell/MediaCard.qml244
-rw-r--r--modules/system/quickshell/MusicVisualizer.qml35
-rw-r--r--modules/system/quickshell/NotificationCard.qml405
-rw-r--r--modules/system/quickshell/NotificationPopupList.qml75
-rw-r--r--modules/system/quickshell/Notifications.qml149
-rw-r--r--modules/system/quickshell/PillSlider.qml77
-rw-r--r--modules/system/quickshell/Polkit.qml222
-rw-r--r--modules/system/quickshell/PopupCard.qml42
-rw-r--r--modules/system/quickshell/SliderBox.qml52
-rw-r--r--modules/system/quickshell/Squircle.qml12
-rw-r--r--modules/system/quickshell/Theme.qml48
-rw-r--r--modules/system/quickshell/ThinSlider.qml40
-rw-r--r--modules/system/quickshell/Toggle.qml31
-rw-r--r--modules/system/quickshell/TrayMenu.qml39
-rw-r--r--modules/system/quickshell/Volume.qml206
-rw-r--r--modules/system/quickshell/VolumeOSD.qml90
-rw-r--r--modules/system/quickshell/Wifi.qml397
-rw-r--r--modules/system/quickshell/WifiPasswordPrompt.qml226
-rw-r--r--modules/system/quickshell/Workspaces.qml99
-rw-r--r--modules/system/quickshell/pam/password.conf1
-rw-r--r--modules/system/quickshell/qmldir33
-rw-r--r--modules/system/quickshell/shell.qml68
-rw-r--r--modules/system/quickshell/squircle.frag47
-rw-r--r--modules/system/quickshell/squircle.qsbbin0 -> 2844 bytes
-rw-r--r--modules/system/quickshell/wallpaper.jpgbin0 -> 2888167 bytes
39 files changed, 4174 insertions, 0 deletions
diff --git a/modules/system/quickshell/Background.qml b/modules/system/quickshell/Background.qml
new file mode 100644
index 0000000..2bf9ae6
--- /dev/null
+++ b/modules/system/quickshell/Background.qml
@@ -0,0 +1,21 @@
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+
+PanelWindow {
+ WlrLayershell.layer: WlrLayer.Background
+ WlrLayershell.exclusiveZone: -1
+
+ anchors {
+ top: true
+ bottom: true
+ left: true
+ right: true
+ }
+
+ Image {
+ anchors.fill: parent
+ source: "./wallpaper.jpg"
+ fillMode: Image.PreserveAspectCrop
+ }
+}
diff --git a/modules/system/quickshell/Bar.qml b/modules/system/quickshell/Bar.qml
new file mode 100644
index 0000000..77cfd1e
--- /dev/null
+++ b/modules/system/quickshell/Bar.qml
@@ -0,0 +1,108 @@
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Services.SystemTray
+import QtQuick
+
+PanelWindow {
+ id: barWindow
+
+ WlrLayershell.layer: WlrLayer.Top
+
+ anchors {
+ top: true
+ left: true
+ right: true
+ }
+
+ implicitHeight: Theme.barHeight
+ color: Theme.barBg
+
+ Item {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+
+ Workspaces {
+ anchors {
+ left: parent.left
+ verticalCenter: parent.verticalCenter
+ }
+ }
+
+ Row {
+ anchors {
+ right: parent.right
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 14
+
+ Repeater {
+ model: SystemTray.items
+
+ delegate: Image {
+ id: trayIcon
+ required property SystemTrayItem modelData
+
+ width: 16
+ height: 16
+ anchors.verticalCenter: parent.verticalCenter
+ source: modelData.icon
+ sourceSize: Qt.size(width, height)
+ smooth: true
+ mipmap: true
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: mouse => {
+ if (mouse.button === Qt.RightButton) {
+ if (modelData.hasMenu && modelData.menu) {
+ trayMenu.menuItem = modelData.menu;
+ trayMenu.open(trayIcon);
+ }
+ } else {
+ modelData.activate()
+ }
+ }
+ }
+ }
+ }
+
+ TrayMenu {
+ id: trayMenu
+ parentWindow: barWindow
+ }
+
+ Bluetooth { anchors.verticalCenter: parent.verticalCenter }
+
+ Wifi { anchors.verticalCenter: parent.verticalCenter }
+
+ Volume { anchors.verticalCenter: parent.verticalCenter }
+
+ Media { anchors.verticalCenter: parent.verticalCenter }
+
+ ControlCenter { anchors.verticalCenter: parent.verticalCenter }
+
+ Text {
+ id: clock
+ anchors.verticalCenter: parent.verticalCenter
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ weight: Font.Medium
+ }
+
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: parent.text = Qt.formatDateTime(new Date(), "ddd d MMM HH:mm:ss")
+ }
+
+ Component.onCompleted: text = Qt.formatDateTime(new Date(), "ddd d MMM HH:mm:ss")
+ }
+ }
+ }
+}
+
diff --git a/modules/system/quickshell/Bluetooth.qml b/modules/system/quickshell/Bluetooth.qml
new file mode 100644
index 0000000..17091ff
--- /dev/null
+++ b/modules/system/quickshell/Bluetooth.qml
@@ -0,0 +1,352 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Bluetooth
+import Quickshell.Widgets
+import QtQuick.Effects // Required for icon tinting
+
+Item {
+ id: root
+
+ Scope {
+ id: internal
+ readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
+
+ readonly property var allDevices: adapter ? adapter.devices.values : []
+
+ property bool hasPaired: false
+ property bool hasNewVisible: false
+
+ function updateState() {
+ let paired = false
+ let newVisible = false
+
+ for (const d of internal.allDevices) {
+ if (d?.paired) paired = true
+ if (d && !d.paired && d.name) newVisible = true
+ }
+
+ internal.hasPaired = paired
+ internal.hasNewVisible = newVisible
+ }
+ }
+
+ width: childrenRect.width
+ height: parent.height
+
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+
+ Image {
+ width: 20
+ height: 20
+ source: Quickshell.iconPath(internal.adapter?.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic")
+ sourceSize: Qt.size(width, height)
+ smooth: true
+ mipmap: true
+ opacity: internal.adapter?.enabled ? 1.0 : 0.5
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ GlobalState.toggle("Bluetooth")
+ } else if (mouse.button === Qt.RightButton && internal.adapter) {
+ internal.adapter.enabled = !internal.adapter.enabled
+ }
+ }
+ }
+
+ PopupWindow {
+ id: popup
+ visible: GlobalState.activePopup === "Bluetooth"
+ grabFocus: true
+ implicitWidth: card.width
+ implicitHeight: card.height
+
+ anchor {
+ window: barWindow
+ item: root
+ edges: Edges.Bottom
+ gravity: Edges.Bottom
+ margins.top: Theme.popupGap
+ }
+
+ color: Theme.transparent
+
+ onVisibleChanged: {
+ if (visible) {
+ anchor.updateAnchor()
+ if (internal.adapter) internal.adapter.discovering = true
+ } else if (internal.adapter?.discovering) {
+ internal.adapter.discovering = false
+ }
+ }
+
+ Connections {
+ target: internal.adapter ? internal.adapter.devices : null
+ function onValuesChanged() { internal.updateState() }
+ }
+
+ PopupCard {
+ id: card
+
+ // Main Toggle Header
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ Text {
+ text: "Bluetooth"
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 14
+ weight: Font.DemiBold
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+
+ Toggle {
+ checked: internal.adapter?.enabled ?? false
+ enabled: internal.adapter !== null
+ onToggled: internal.adapter.enabled = !internal.adapter.enabled
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 1
+ color: Theme.border
+ }
+
+ // Paired Devices Header
+ Text {
+ visible: internal.hasPaired
+ text: "My Devices" // macOS typically labels this "My Devices"
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.DemiBold
+ }
+ }
+
+ // Paired Devices List
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+ visible: internal.hasPaired
+
+ Repeater {
+ model: internal.adapter ? internal.adapter.devices : null
+
+ delegate: Squircle {
+ id: pairedItem
+ required property BluetoothDevice modelData
+ readonly property bool hovered: pairedArea.containsMouse
+ visible: pairedItem.modelData?.paired ?? false
+ Layout.fillWidth: true
+ Layout.preferredHeight: visible ? 36 : 0
+ fillColor: hovered ? Theme.surface : Theme.transparent
+ cornerRadius: 6
+
+ Connections {
+ target: pairedItem.modelData
+ function onPairedChanged() { internal.updateState() }
+ function onConnectedChanged() { internal.updateState() }
+ function onNameChanged() { internal.updateState() }
+ }
+ Component.onCompleted: internal.updateState()
+ Component.onDestruction: internal.updateState()
+
+ MouseArea {
+ id: pairedArea
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ const d = pairedItem.modelData
+ if (d.connected) d.disconnect()
+ else d.connect()
+ }
+ }
+
+ RowLayout {
+ anchors {
+ fill: parent
+ margins: 8
+ }
+ spacing: 10
+
+ // macOS uses bare icons in the list, tinted accent color when connected
+ Item {
+ Layout.preferredWidth: 20
+ Layout.preferredHeight: 20
+
+ Image {
+ id: deviceIcon
+ anchors.fill: parent
+ source: Quickshell.iconPath("bluetooth-active-symbolic")
+ sourceSize: Qt.size(20, 20)
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: deviceIcon
+ source: deviceIcon
+ colorizationColor: (pairedItem.modelData?.connected ?? false) ? Theme.accent : Theme.text
+ colorization: 1.0
+ }
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: pairedItem.modelData?.name || pairedItem.modelData?.deviceName || pairedItem.modelData?.address || ""
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ }
+ elide: Text.ElideRight
+ }
+
+ // macOS displays connection status text instead of a toggle/button
+ Text {
+ text: (pairedItem.modelData?.connected ?? false) ? "Connected" : "Not Connected"
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 11
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 1
+ color: Theme.border
+ visible: internal.hasPaired
+ }
+
+ // Other Devices Header
+ RowLayout {
+ Layout.fillWidth: true
+
+ Text {
+ text: "Other Devices"
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.DemiBold
+ }
+ Layout.fillWidth: true
+ }
+
+ // Passive scanning indicator instead of interactive refresh button
+ Image {
+ width: 14
+ height: 14
+ source: Quickshell.iconPath("process-working-symbolic")
+ sourceSize: Qt.size(width, height)
+ smooth: true
+ mipmap: true
+ visible: internal.adapter?.discovering ?? false
+ opacity: 0.6
+
+ RotationAnimation on rotation {
+ running: parent.visible
+ from: 0
+ to: 360
+ duration: 1000
+ loops: Animation.Infinite
+ }
+ }
+ }
+
+ // Unpaired Devices List
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+ visible: internal.hasNewVisible
+
+ Repeater {
+ model: internal.adapter ? internal.adapter.devices : null
+
+ delegate: Squircle {
+ id: newItem
+ required property BluetoothDevice modelData
+ visible: !(newItem.modelData?.paired ?? true) && (newItem.modelData?.name ?? "") !== ""
+ Layout.fillWidth: true
+ Layout.preferredHeight: visible ? 36 : 0
+ fillColor: newArea.containsMouse ? Theme.surface : Theme.transparent
+ cornerRadius: 6
+
+ Connections {
+ target: newItem.modelData
+ function onPairedChanged() { internal.updateState() }
+ function onNameChanged() { internal.updateState() }
+ }
+ Component.onCompleted: internal.updateState()
+ Component.onDestruction: internal.updateState()
+
+ MouseArea {
+ id: newArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: !(newItem.modelData?.pairing ?? false)
+ onClicked: newItem.modelData.pair()
+ }
+
+ RowLayout {
+ anchors {
+ fill: parent
+ margins: 8
+ }
+ spacing: 10
+
+ Image {
+ Layout.preferredWidth: 20
+ Layout.preferredHeight: 20
+ source: Quickshell.iconPath("bluetooth-active-symbolic")
+ sourceSize: Qt.size(20, 20)
+ opacity: 0.6
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: newItem.modelData?.name || newItem.modelData?.address || ""
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ }
+ elide: Text.ElideRight
+ }
+
+ // Replace + icon with "Connecting..." text when pairing
+ Text {
+ visible: newItem.modelData?.pairing ?? false
+ text: "Connecting..."
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 11
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/BrightnessService.qml b/modules/system/quickshell/BrightnessService.qml
new file mode 100644
index 0000000..991a6ec
--- /dev/null
+++ b/modules/system/quickshell/BrightnessService.qml
@@ -0,0 +1,36 @@
+import QtQuick
+import Quickshell
+import Quickshell.Io
+
+// Simplified brightness service using brightnessctl.
+QtObject {
+ id: root
+ property real brightness: 0
+ property int maxBrightness: 1
+
+ function update() {
+ updateProc.running = true
+ }
+
+ function setBrightness(value) {
+ const raw = Math.round(value * maxBrightness)
+ Quickshell.execDetached(["brightnessctl", "s", raw.toString()])
+ brightness = value
+ }
+
+ readonly property Process updateProc: Process {
+ command: ["sh", "-c", "brightnessctl g && brightnessctl m"]
+ stdout: SplitParser {
+ onRead: data => {
+ const lines = data.trim().split("\n")
+ if (lines.length >= 2) {
+ const current = parseInt(lines[0])
+ root.maxBrightness = parseInt(lines[1])
+ root.brightness = current / root.maxBrightness
+ }
+ }
+ }
+ }
+
+ Component.onCompleted: update()
+}
diff --git a/modules/system/quickshell/ConnectivityBox.qml b/modules/system/quickshell/ConnectivityBox.qml
new file mode 100644
index 0000000..e1fb03f
--- /dev/null
+++ b/modules/system/quickshell/ConnectivityBox.qml
@@ -0,0 +1,121 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Networking
+import Quickshell.Bluetooth
+
+Squircle {
+ id: root
+ cornerRadius: 16
+ fillColor: Theme.surface
+ Layout.fillWidth: true
+ Layout.preferredHeight: 140
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 0 // Using spacers for better control
+
+ // WiFi Row
+ Item {
+ Layout.fillWidth: true
+ height: 50
+ scale: wifiArea.pressed ? 0.96 : 1.0
+ Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 12
+
+ IconCircle {
+ source: "network-wireless"
+ active: Networking.wifiEnabled
+ size: 32
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ ColumnLayout {
+ spacing: 0
+ Layout.alignment: Qt.AlignVCenter
+ Layout.fillWidth: true
+ Text {
+ text: "Wi-Fi"
+ color: Theme.text
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ }
+ Text {
+ text: {
+ const vs = Networking.devices?.values || []
+ for (const device of vs) {
+ if (device.scannerEnabled !== undefined) {
+ const nets = device.networks?.values || []
+ for (const n of nets) if (n.connected) return n.name
+ }
+ }
+ return Networking.wifiEnabled ? "On" : "Off"
+ }
+ color: Theme.textMuted
+ font.pixelSize: 12
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ Layout.maximumWidth: 80
+ }
+ }
+ }
+
+ MouseArea {
+ id: wifiArea
+ anchors.fill: parent
+ onClicked: Qt.callLater(() => GlobalState.open("Wifi"))
+ }
+ }
+
+ Item { Layout.fillHeight: true } // Spacer
+
+ // Bluetooth Row
+ Item {
+ Layout.fillWidth: true
+ height: 50
+ scale: btArea.pressed ? 0.96 : 1.0
+ Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
+
+ RowLayout {
+ anchors.fill: parent
+ spacing: 12
+
+ IconCircle {
+ source: "bluetooth-active"
+ active: Bluetooth.defaultAdapter?.enabled ?? false
+ size: 32
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ ColumnLayout {
+ spacing: 0
+ Layout.alignment: Qt.AlignVCenter
+ Layout.fillWidth: true
+ Text {
+ text: "Bluetooth"
+ color: Theme.text
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ }
+ Text {
+ text: Bluetooth.defaultAdapter?.enabled ? "On" : "Off"
+ color: Theme.textMuted
+ font.pixelSize: 12
+ font.weight: Font.Medium
+ Layout.fillWidth: true
+ }
+ }
+ }
+
+ MouseArea {
+ id: btArea
+ anchors.fill: parent
+ onClicked: Qt.callLater(() => GlobalState.open("Bluetooth"))
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/ControlCenter.qml b/modules/system/quickshell/ControlCenter.qml
new file mode 100644
index 0000000..25aae5e
--- /dev/null
+++ b/modules/system/quickshell/ControlCenter.qml
@@ -0,0 +1,141 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Networking
+import Quickshell.Bluetooth
+import Quickshell.Services.Pipewire
+import Quickshell.Services.Mpris
+
+Item {
+ id: root
+ width: childrenRect.width
+ height: parent.height
+
+ BrightnessService { id: brightnessService }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: GlobalState.toggle("ControlCenter")
+ }
+
+ // Indicator in bar
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 4
+ Image {
+ width: 20; height: 20
+ source: Quickshell.iconPath("emblem-system-symbolic")
+ sourceSize: Qt.size(width, height)
+ smooth: true
+ mipmap: true
+ }
+ }
+
+ PopupWindow {
+ id: popup
+ visible: GlobalState.activePopup === "ControlCenter"
+ grabFocus: true
+ implicitWidth: card.width
+ implicitHeight: card.height
+
+ anchor {
+ window: barWindow
+ item: root
+ edges: Edges.Bottom
+ gravity: Edges.Bottom
+ margins.top: Theme.popupGap
+ }
+
+ color: "transparent"
+
+ onVisibleChanged: {
+ if (visible) anchor.updateAnchor()
+ }
+
+ PopupCard {
+ id: card
+ width: 320
+ margins: 16
+
+ // TOP SECTION: Connectivity & Quick Actions
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 12
+
+ ConnectivityBox {
+ Layout.fillWidth: true
+ }
+
+ ColumnLayout {
+ spacing: 12
+ ControlTile {
+ label: "Do Not Disturb"
+ icon: "notifications"
+ }
+ ControlTile {
+ icon: "network-wireless"
+ }
+ }
+ }
+
+ // MIDDLE SECTION: Sliders
+ SliderBox {
+ label: "Display"
+ icon: "display-brightness"
+ value: brightnessService.brightness
+ onMoved: val => brightnessService.setBrightness(val)
+ }
+
+ SliderBox {
+ label: "Sound"
+ icon: "audio-volume-high"
+ value: Pipewire.defaultAudioSink?.audio?.volume ?? 0
+ onMoved: val => {
+ const sink = Pipewire.defaultAudioSink
+ if (sink?.audio) {
+ sink.audio.muted = false
+ sink.audio.volume = val
+ }
+ }
+ }
+
+ // NOW PLAYING BOX
+ Squircle {
+ Layout.fillWidth: true
+ height: 64
+ cornerRadius: 16
+ fillColor: hoverArea.containsMouse ? Theme.surfaceHover : Theme.surface
+ visible: (Mpris.players?.values?.length ?? 0) > 0
+
+ MouseArea {
+ id: hoverArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ }
+
+ MediaCard {
+ anchors.verticalCenter: parent.verticalCenter
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+
+ isExpanded: false
+
+ readonly property var activePlayer: {
+ const ps = Mpris.players?.values || []
+ for (const p of ps) if (p.playbackState === MprisPlaybackState.Playing) return p
+ return ps[0]
+ }
+
+ player: activePlayer
+
+ onClicked: Qt.callLater(() => GlobalState.open("Media"))
+ }
+ }
+ }
+ }
+}
+
+
diff --git a/modules/system/quickshell/ControlTile.qml b/modules/system/quickshell/ControlTile.qml
new file mode 100644
index 0000000..2c8e0a6
--- /dev/null
+++ b/modules/system/quickshell/ControlTile.qml
@@ -0,0 +1,50 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+
+Item {
+ id: root
+ property string icon: ""
+ property string label: ""
+ property bool active: false
+ property var clickHandler: null
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: 64
+
+ Squircle {
+ anchors.fill: parent
+ cornerRadius: 16
+ fillColor: root.active ? Theme.accent : Theme.surface
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ if (root.clickHandler) root.clickHandler()
+ }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 12
+
+ IconCircle {
+ source: root.icon
+ active: root.active
+ }
+
+ Text {
+ text: root.label
+ color: root.active ? Theme.bg : Theme.text
+ font.family: Theme.mainFont
+ font.pixelSize: 11
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ Layout.maximumWidth: 80
+ visible: root.label !== ""
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/modules/system/quickshell/CustomCheckBox.qml b/modules/system/quickshell/CustomCheckBox.qml
new file mode 100644
index 0000000..9b7014d
--- /dev/null
+++ b/modules/system/quickshell/CustomCheckBox.qml
@@ -0,0 +1,44 @@
+import QtQuick
+import QtQuick.Controls
+
+CheckBox {
+ id: control
+
+ contentItem: Text {
+ text: control.text
+ color: Theme.text
+ font.family: Theme.mainFont
+ font.pixelSize: 13
+ verticalAlignment: Text.AlignVCenter
+ leftPadding: control.indicator.width + control.spacing
+ }
+
+ indicator: Rectangle {
+ implicitWidth: 14
+ implicitHeight: 14
+ x: control.leftPadding
+ y: Math.round((control.height - height) / 2)
+ radius: 3.5
+ color: control.checked ? Theme.accent : Theme.surface
+ border.color: control.checked ? Theme.accent : Theme.border
+ border.width: 1
+
+ Canvas {
+ anchors.fill: parent
+ visible: control.checked
+ onPaint: {
+ var ctx = getContext("2d");
+ ctx.reset();
+ ctx.lineWidth = 1.5;
+ ctx.strokeStyle = "white";
+ ctx.lineCap = "round";
+ ctx.lineJoin = "round";
+ ctx.beginPath();
+ ctx.moveTo(3, 7);
+ ctx.lineTo(6, 10);
+ ctx.lineTo(11, 4);
+ ctx.stroke();
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/GlobalState.qml b/modules/system/quickshell/GlobalState.qml
new file mode 100644
index 0000000..c212967
--- /dev/null
+++ b/modules/system/quickshell/GlobalState.qml
@@ -0,0 +1,23 @@
+import QtQuick
+
+pragma Singleton
+
+// Shared state to coordinate which popup is currently open.
+// Ensures only one menu is visible at a time.
+QtObject {
+ property string activePopup: ""
+
+ function open(name) {
+ activePopup = ""
+ activePopup = name
+ }
+
+ function close() {
+ activePopup = ""
+ }
+
+ function toggle(name) {
+ if (activePopup === name) activePopup = ""
+ else activePopup = name
+ }
+}
diff --git a/modules/system/quickshell/IconCircle.qml b/modules/system/quickshell/IconCircle.qml
new file mode 100644
index 0000000..50d5dd6
--- /dev/null
+++ b/modules/system/quickshell/IconCircle.qml
@@ -0,0 +1,38 @@
+import QtQuick
+import QtQuick.Effects
+import Quickshell
+
+Rectangle {
+ id: root
+
+ property string source: ""
+ property bool active: false
+ property real size: 24
+
+ width: size
+ height: size
+ radius: size / 2
+ color: active ? Theme.accent : Theme.surfaceLighter
+
+ Image {
+ id: iconImage
+ anchors.centerIn: parent
+ width: parent.width * 0.6
+ height: parent.height * 0.6
+ source: Quickshell.iconPath(root.source.endsWith("-symbolic") ? root.source : root.source + "-symbolic")
+ sourceSize: Qt.size(width, height)
+ smooth: true
+ mipmap: true
+
+ visible: false
+ }
+
+ MultiEffect {
+ anchors.fill: iconImage
+ source: iconImage
+ colorizationColor: "#FFFFFF"
+ colorization: 1.0
+
+ opacity: root.active ? 1.0 : 0.6
+ }
+}
diff --git a/modules/system/quickshell/Launcher.qml b/modules/system/quickshell/Launcher.qml
new file mode 100644
index 0000000..fc0dc3d
--- /dev/null
+++ b/modules/system/quickshell/Launcher.qml
@@ -0,0 +1,228 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Wayland
+
+PanelWindow {
+ id: root
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "launcher"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+ WlrLayershell.exclusiveZone: -1
+
+ exclusionMode: ExclusionMode.Ignore
+
+ // Centering logic
+ anchors {
+ top: true
+ }
+ margins.top: 200
+
+ implicitWidth: 600
+ implicitHeight: 400
+
+ color: "transparent"
+ visible: GlobalState.activePopup === "Launcher"
+
+ onVisibleChanged: {
+ if (visible) {
+ searchInput.forceActiveFocus()
+ searchInput.text = ""
+ }
+ }
+
+ Scope {
+ id: internal
+
+ function filterApps(searchText) {
+ const search = searchText.toLowerCase()
+ const apps = DesktopEntries.applications.values
+
+ return apps.filter(app => {
+ if (app.noDisplay) return false
+ if (!search) return true
+
+ return app.name.toLowerCase().includes(search) ||
+ (app.comment && app.comment.toLowerCase().includes(search))
+ })
+ }
+ }
+
+ FocusScope {
+ anchors.fill: parent
+ focus: true
+
+ Squircle {
+ id: launcherContent
+ anchors.fill: parent
+ cornerRadius: 12
+ fillColor: Theme.barBg
+ strokeColor: Theme.border
+ strokeWidth: 1
+
+ // Capture Escape to close
+ Keys.onEscapePressed: GlobalState.close()
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 8
+
+ // Search Header
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 12
+
+ Image {
+ width: 24; height: 24
+ source: Quickshell.iconPath("system-search-symbolic")
+ sourceSize: Qt.size(24, 24)
+ smooth: true
+ mipmap: true
+ opacity: 0.7
+ }
+
+ TextInput {
+ id: searchInput
+ Layout.fillWidth: true
+ font {
+ family: Theme.mainFont
+ pixelSize: 20
+ }
+ color: Theme.text
+ clip: true
+
+ focus: true
+ cursorVisible: true
+ selectByMouse: true
+ inputMethodHints: Qt.ImhNoPredictiveText
+
+ Text {
+ visible: searchInput.text === ""
+ text: "Search Applications..."
+ color: Theme.text
+ opacity: 0.3
+ font: searchInput.font
+ }
+
+ onTextChanged: resultsList.currentIndex = 0
+
+ Keys.onPressed: event => {
+ if (event.key === Qt.Key_Down) {
+ resultsList.incrementCurrentIndex()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Up) {
+ resultsList.decrementCurrentIndex()
+ event.accepted = true
+ } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
+ if (resultsList.count > 0 && resultsList.currentIndex >= 0) {
+ if (resultsList.currentItem) {
+ resultsList.currentItem.launch()
+ }
+ }
+ event.accepted = true
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ height: 1
+ color: Theme.border
+ }
+
+ ListView {
+ id: resultsList
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ boundsBehavior: Flickable.StopAtBounds
+ highlightFollowsCurrentItem: true
+ highlightMoveDuration: 150
+ highlightResizeDuration: 0
+
+ highlight: Squircle {
+ width: resultsList.width
+ height: 44
+ cornerRadius: 8
+ fillColor: Theme.surface
+ z: 1
+ }
+
+ model: internal.filterApps(searchInput.text)
+
+ delegate: Squircle {
+ id: delegateRoot
+ required property var modelData
+ required property int index
+
+ height: 44
+ width: resultsList.width
+
+ cornerRadius: 8
+ fillColor: "transparent"
+ z: 5
+
+ function launch() {
+ if (modelData && modelData.execute) {
+ modelData.execute()
+ GlobalState.close()
+ }
+ }
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 12
+
+ Image {
+ width: 24; height: 24
+ source: Quickshell.iconPath(modelData.icon)
+ sourceSize: Qt.size(32, 32)
+ smooth: true
+ mipmap: true
+ }
+
+ Column {
+ Layout.fillWidth: true
+ Text {
+ text: delegateRoot.modelData.name
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 14
+ weight: Font.Medium
+ }
+ }
+ Text {
+ visible: delegateRoot.modelData.comment !== ""
+ text: delegateRoot.modelData.comment
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 11
+ }
+ elide: Text.ElideRight
+ width: parent.width
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: resultsList.currentIndex = index
+ onClicked: delegateRoot.launch()
+ }
+ }
+
+ ScrollBar.vertical: ScrollBar {}
+ }
+ }
+ }
+}
+}
diff --git a/modules/system/quickshell/LockContext.qml b/modules/system/quickshell/LockContext.qml
new file mode 100644
index 0000000..05fd1de
--- /dev/null
+++ b/modules/system/quickshell/LockContext.qml
@@ -0,0 +1,58 @@
+import QtQuick
+import Quickshell
+import Quickshell.Services.Pam
+
+Scope {
+ id: root
+ signal unlocked()
+ signal failed()
+
+ property string currentText: ""
+ property bool unlockInProgress: false
+ property string pamMessage: ""
+ property bool pamError: false
+
+ onCurrentTextChanged: {
+ pamError = false;
+ pamMessage = "";
+ }
+
+ function tryUnlock() {
+ if (currentText === "" || unlockInProgress) return;
+ unlockInProgress = true;
+ pam.start();
+ }
+
+ function reset() {
+ currentText = "";
+ pamError = false;
+ pamMessage = "";
+ unlockInProgress = false;
+ }
+
+ PamContext {
+ id: pam
+ configDirectory: "pam"
+ config: "password.conf"
+
+ onPamMessage: {
+ root.pamMessage = pam.message;
+ root.pamError = pam.messageIsError;
+ if (this.responseRequired) {
+ this.respond(root.currentText);
+ }
+ }
+
+ onCompleted: result => {
+ if (result == PamResult.Success) {
+ root.currentText = "";
+ root.unlocked();
+ } else {
+ root.currentText = "";
+ root.pamError = true;
+ root.failed();
+ }
+ root.unlockInProgress = false;
+ }
+ }
+}
diff --git a/modules/system/quickshell/LockSurface.qml b/modules/system/quickshell/LockSurface.qml
new file mode 100644
index 0000000..eacd98f
--- /dev/null
+++ b/modules/system/quickshell/LockSurface.qml
@@ -0,0 +1,197 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+
+Item {
+ id: root
+ required property LockContext context
+ focus: true
+
+ property bool showInput: false
+ property string realName: ""
+
+ Process {
+ id: nameProc
+ command: ["sh", "-c", "NAME=$(getent passwd $USER | cut -d: -f5 | cut -d, -f1); if [ -n \"$NAME\" ]; then echo \"$NAME\"; else whoami; fi"]
+ running: true
+ stdout: SplitParser {
+ onRead: line => {
+ root.realName = line.trim()
+ }
+ }
+ }
+
+ // Capture keyboard input to reveal the text field
+ Keys.onPressed: (event) => {
+ if (!showInput && event.key !== Qt.Key_Escape) {
+ showInput = true
+ passwordInput.forceActiveFocus()
+ if (event.text !== "") {
+ passwordInput.text = event.text
+ root.context.currentText = event.text
+ }
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ root.showInput = true
+ passwordInput.forceActiveFocus()
+ }
+ }
+
+ Image {
+ anchors.fill: parent
+ source: "./wallpaper.jpg"
+ fillMode: Image.PreserveAspectCrop
+ }
+
+ // Main Content (Clock)
+ Column {
+ anchors {
+ top: parent.top
+ topMargin: 120
+ horizontalCenter: parent.horizontalCenter
+ }
+ spacing: 0
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: Qt.formatDateTime(new Date(), "dddd, MMMM d")
+ color: "white"
+ opacity: 0.9
+ font {
+ family: Theme.mainFont
+ pixelSize: 24
+ weight: Font.Medium
+ }
+ }
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: Qt.formatDateTime(new Date(), "HH:mm")
+ color: "white"
+ font {
+ family: Theme.mainFont
+ pixelSize: 150
+ weight: Font.DemiBold
+ letterSpacing: -8
+ }
+ } }
+
+ // Profile and Password Area (Bottom Center)
+ ColumnLayout {
+ id: passwordContainer
+ anchors {
+ bottom: parent.bottom
+ horizontalCenter: parent.horizontalCenter
+ bottomMargin: 100
+ }
+ spacing: 15
+
+ SequentialAnimation {
+ id: shakeAnimation
+ NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: -6; duration: 30; easing.type: Easing.OutQuad }
+ NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 6; duration: 60; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: -6; duration: 60; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 6; duration: 60; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: -6; duration: 60; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 6; duration: 60; easing.type: Easing.InOutQuad }
+ NumberAnimation { target: passwordContainer; property: "anchors.horizontalCenterOffset"; to: 0; duration: 30; easing.type: Easing.InQuad }
+ }
+
+ Connections {
+ target: root.context
+ function onFailed() {
+ shakeAnimation.start();
+ }
+ }
+
+ // Avatar Placeholder
+ Rectangle {
+ Layout.alignment: Qt.AlignHCenter
+ width: 60
+ height: 60
+ radius: 30
+ color: "#b0b0b0"
+ }
+
+ // User Name
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ text: root.realName || "User"
+ color: "white"
+ font {
+ family: Theme.mainFont
+ pixelSize: 16
+ weight: Font.DemiBold
+ }
+ visible: !root.showInput
+ }
+
+ // Prompt Text
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ text: "Enter Password"
+ color: "white"
+ opacity: 0.7
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ }
+ visible: !root.showInput
+ }
+
+ // Password Input Field
+ TextField {
+ id: passwordInput
+ Layout.preferredWidth: 220
+ Layout.preferredHeight: 32
+ Layout.alignment: Qt.AlignHCenter
+ visible: root.showInput
+
+ placeholderText: root.context.pamMessage || "Enter Password"
+ placeholderTextColor: Theme.textPlaceholder
+ echoMode: TextInput.Password
+ inputMethodHints: Qt.ImhSensitiveData
+ enabled: !root.context.unlockInProgress
+
+ // Update context when text is changed directly in this field
+ onTextChanged: root.context.currentText = this.text
+
+ // Sync text from context to support multi-monitor mirroring securely
+ Connections {
+ target: root.context
+ function onCurrentTextChanged() {
+ if (passwordInput.text !== root.context.currentText) {
+ passwordInput.text = root.context.currentText;
+ }
+ }
+ }
+
+ background: Rectangle {
+ radius: height / 2
+ color: Theme.surface
+ border.color: parent.activeFocus ? Theme.accent : Theme.border
+ border.width: 1
+ }
+
+ color: Theme.text
+ font.pixelSize: 13
+ horizontalAlignment: TextInput.AlignHCenter
+ verticalAlignment: TextInput.AlignVCenter
+
+ onAccepted: {
+ root.context.tryUnlock()
+ }
+
+ Keys.onEscapePressed: {
+ root.showInput = false
+ root.context.currentText = ""
+ root.forceActiveFocus()
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/Media.qml b/modules/system/quickshell/Media.qml
new file mode 100644
index 0000000..ee5bf42
--- /dev/null
+++ b/modules/system/quickshell/Media.qml
@@ -0,0 +1,119 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Services.Mpris
+
+Item {
+ id: root
+
+ property QtObject manualExpandedPlayer: null
+
+ readonly property var activePlayers: Mpris.players?.values || []
+
+ readonly property var expandedPlayer: {
+ if (root.manualExpandedPlayer !== null && root.activePlayers.includes(root.manualExpandedPlayer)) {
+ return root.manualExpandedPlayer;
+ }
+ for (const player of root.activePlayers) {
+ if (player.playbackState === MprisPlaybackState.Playing) {
+ return player;
+ }
+ }
+ if (root.activePlayers.length > 0) {
+ return root.activePlayers[0];
+ }
+ return null;
+ }
+
+ width: childrenRect.width
+ height: parent.height
+
+ Row {
+ anchors.verticalCenter: parent.verticalCenter
+
+ MusicVisualizer {
+ active: root.expandedPlayer?.playbackState === MprisPlaybackState.Playing
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: GlobalState.toggle("Media")
+ }
+
+ PopupWindow {
+ id: popup
+ visible: GlobalState.activePopup === "Media"
+ grabFocus: true
+ implicitWidth: card.width
+ implicitHeight: card.height
+
+ anchor {
+ window: barWindow
+ item: root
+ edges: Edges.Bottom
+ gravity: Edges.Bottom
+ margins.top: Theme.popupGap
+ }
+
+ color: Theme.transparent
+
+ PopupCard {
+ id: card
+ width: 320
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 0
+ Layout.margins: 8
+
+ Repeater {
+ model: root.activePlayers.length > 0 ? root.activePlayers : [null]
+
+ delegate: Item {
+ id: playerDelegate
+ Layout.fillWidth: true
+ implicitHeight: mediaCard.implicitHeight + (separator.visible ? separator.height + 16 : 0)
+
+ required property var modelData
+ required property int index
+
+ readonly property bool isEmpty: modelData === null
+ readonly property bool isExpanded: root.expandedPlayer === modelData && !isEmpty
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 16
+
+ MediaCard {
+ id: mediaCard
+ Layout.fillWidth: true
+ player: playerDelegate.modelData
+ isExpanded: playerDelegate.isExpanded
+
+ onClicked: {
+ if (!playerDelegate.isEmpty) {
+ root.manualExpandedPlayer = playerDelegate.modelData;
+ } else {
+ Quickshell.execDetached(["spotify"]);
+ }
+ }
+ }
+
+ Rectangle {
+ id: separator
+ Layout.fillWidth: true
+ height: 1
+ color: Theme.border
+ opacity: 0.3
+ visible: !playerDelegate.isEmpty && playerDelegate.index < root.activePlayers.length - 1
+ Layout.topMargin: 4
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/MediaCard.qml b/modules/system/quickshell/MediaCard.qml
new file mode 100644
index 0000000..5aba34c
--- /dev/null
+++ b/modules/system/quickshell/MediaCard.qml
@@ -0,0 +1,244 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Services.Mpris
+
+Item {
+ id: root
+
+ property QtObject player: null
+ property bool isExpanded: false
+ signal clicked()
+
+ Layout.fillWidth: true
+ implicitHeight: layout.implicitHeight
+
+ Behavior on implicitHeight {
+ NumberAnimation { duration: 200; easing.type: Easing.OutCubic }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: !root.isExpanded
+ onClicked: root.clicked()
+ }
+
+ function formatTime(s) {
+ const mins = Math.floor(s / 60)
+ const secs = Math.floor(s % 60)
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+ }
+
+ ColumnLayout {
+ id: layout
+ anchors.left: parent.left
+ anchors.right: parent.right
+ spacing: 16
+
+ // Top Row: Minimal view and header for expanded view
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 12
+
+ Squircle {
+ width: root.isExpanded ? 56 : 40
+ height: root.isExpanded ? 56 : 40
+ cornerRadius: root.isExpanded ? 10 : 8
+ fillColor: root.player ? Theme.transparent : Theme.surface
+ clip: true
+
+ Behavior on width { NumberAnimation { duration: 200 } }
+ Behavior on height { NumberAnimation { duration: 200 } }
+ Behavior on cornerRadius { NumberAnimation { duration: 200 } }
+
+ Image {
+ anchors.fill: parent
+ source: root.player?.trackArtUrl || ""
+ fillMode: Image.PreserveAspectCrop
+ visible: status === Image.Ready && root.player !== null
+ }
+
+ IconCircle {
+ anchors.centerIn: parent
+ size: root.isExpanded ? 28 : 20
+ source: "audio-x-generic-symbolic"
+ visible: !root.player?.trackArtUrl || parent.children[0].status !== Image.Ready || root.player === null
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ spacing: 2
+
+ Text {
+ Layout.fillWidth: true
+ text: root.player?.trackTitle || "Music"
+ color: root.player ? Theme.text : Theme.textDisabled
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ elide: Text.ElideRight
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: root.player?.trackArtist || ""
+ visible: text !== "" && root.player !== null
+ color: Theme.textDim
+ font.pixelSize: 12
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: root.player?.trackAlbum || ""
+ visible: root.isExpanded && text !== "" && root.player !== null
+ color: Theme.textDisabled
+ font.pixelSize: 11
+ font.weight: Font.Normal
+ elide: Text.ElideRight
+ }
+ }
+
+ // Minimal Controls: Only visible when not expanded
+ Row {
+ visible: !root.isExpanded
+ spacing: 12
+ Layout.alignment: Qt.AlignVCenter
+
+ Item {
+ width: 24; height: 24
+ Image {
+ anchors.centerIn: parent
+ source: Quickshell.iconPath(root.player?.playbackState === MprisPlaybackState.Playing ? "media-playback-pause-symbolic" : "media-playback-start-symbolic")
+ sourceSize: Qt.size(24, 24)
+ opacity: root.player?.canTogglePlaying ? (minPlayMouse.pressed ? 0.7 : 1.0) : 0.3
+ }
+ MouseArea {
+ id: minPlayMouse
+ anchors.fill: parent
+ enabled: root.player?.canTogglePlaying ?? false
+ onClicked: if (root.player) root.player.togglePlaying()
+ }
+ }
+
+ Item {
+ width: 24; height: 24
+ Image {
+ anchors.centerIn: parent
+ source: Quickshell.iconPath("media-skip-forward-symbolic")
+ sourceSize: Qt.size(20, 20)
+ opacity: root.player?.canGoNext ? (minNextMouse.pressed ? 0.5 : 0.8) : 0.3
+ }
+ MouseArea {
+ id: minNextMouse
+ anchors.fill: parent
+ enabled: root.player?.canGoNext ?? false
+ onClicked: if (root.player) root.player.next()
+ }
+ }
+ }
+ }
+
+ // Expanded Controls: Only visible when expanded and a player exists
+ ColumnLayout {
+ visible: root.isExpanded && root.player !== null
+ Layout.fillWidth: true
+ spacing: 8
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+
+ ThinSlider {
+ id: progressSlider
+ Layout.fillWidth: true
+ from: 0
+ to: root.player?.length || 1
+ value: root.player?.position || 0
+ enabled: root.player?.canSeek ?? false
+
+ onMoved: if (root.player) root.player.position = value
+
+ Timer {
+ running: root.player?.playbackState === MprisPlaybackState.Playing && root.isExpanded
+ interval: 500
+ repeat: true
+ onTriggered: progressSlider.value = root.player.position
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Text {
+ text: root.formatTime(root.player?.position || 0)
+ color: Theme.textPlaceholder
+ font.pixelSize: 9
+ font.weight: Font.Medium
+ }
+ Item { Layout.fillWidth: true }
+ Text {
+ text: root.formatTime(root.player?.length || 0)
+ color: Theme.textPlaceholder
+ font.pixelSize: 9
+ font.weight: Font.Medium
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.alignment: Qt.AlignHCenter
+ spacing: 32
+
+ Item {
+ width: 24; height: 24
+ Image {
+ anchors.centerIn: parent
+ source: Quickshell.iconPath("media-skip-backward-symbolic")
+ sourceSize: Qt.size(20, 20)
+ opacity: root.player?.canGoPrevious ? (prevMouse.pressed ? 0.5 : 0.8) : 0.3
+ }
+ MouseArea {
+ id: prevMouse
+ anchors.fill: parent
+ enabled: root.player?.canGoPrevious ?? false
+ onClicked: if (root.player) root.player.previous()
+ }
+ }
+
+ Item {
+ width: 32; height: 32
+ Image {
+ anchors.centerIn: parent
+ source: Quickshell.iconPath(root.player?.playbackState === MprisPlaybackState.Playing ? "media-playback-pause-symbolic" : "media-playback-start-symbolic")
+ sourceSize: Qt.size(28, 28)
+ opacity: root.player?.canTogglePlaying ? (maxPlayMouse.pressed ? 0.7 : 1.0) : 0.3
+ }
+ MouseArea {
+ id: maxPlayMouse
+ anchors.fill: parent
+ enabled: root.player?.canTogglePlaying ?? false
+ onClicked: if (root.player) root.player.togglePlaying()
+ }
+ }
+
+ Item {
+ width: 24; height: 24
+ Image {
+ anchors.centerIn: parent
+ source: Quickshell.iconPath("media-skip-forward-symbolic")
+ sourceSize: Qt.size(20, 20)
+ opacity: root.player?.canGoNext ? (maxNextMouse.pressed ? 0.5 : 0.8) : 0.3
+ }
+ MouseArea {
+ id: maxNextMouse
+ anchors.fill: parent
+ enabled: root.player?.canGoNext ?? false
+ onClicked: if (root.player) root.player.next()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/MusicVisualizer.qml b/modules/system/quickshell/MusicVisualizer.qml
new file mode 100644
index 0000000..e136304
--- /dev/null
+++ b/modules/system/quickshell/MusicVisualizer.qml
@@ -0,0 +1,35 @@
+import QtQuick
+
+// iOS-style animated music visualizer bars.
+// Simulates audio reactivity by randomly changing bar heights when active.
+Row {
+ id: root
+ property bool active: false
+ property color color: Theme.accent
+ spacing: 2
+ height: 16
+
+ Repeater {
+ model: 4
+ delegate: Rectangle {
+ id: bar
+ width: 3
+ height: root.active ? 4 + Math.random() * (root.height - 4) : 4
+ radius: 1.5
+ color: root.color
+
+ Timer {
+ running: root.active
+ interval: 100 + Math.random() * 200
+ repeat: true
+ onTriggered: {
+ bar.height = 4 + Math.random() * (root.height - 4)
+ }
+ }
+
+ Behavior on height {
+ NumberAnimation { duration: 150; easing.type: Easing.OutCubic }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/NotificationCard.qml b/modules/system/quickshell/NotificationCard.qml
new file mode 100644
index 0000000..8b6478a
--- /dev/null
+++ b/modules/system/quickshell/NotificationCard.qml
@@ -0,0 +1,405 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Effects
+import Quickshell
+
+Item {
+ id: card
+
+ required property int nId
+ required property string nSummary
+ required property string nBody
+ required property string nAppName
+ required property string nAppIcon
+ required property string nImage
+ required property var nTimestamp
+ required property int nTimeout
+ required property var nActions
+ required property bool nClickable
+ required property bool nHasInlineReply
+ required property string nReplyPlaceholder
+ required property bool nResident
+
+ property bool hovered: cardHover.containsMouse || closeHover.containsMouse || (replyInput && replyInput.activeFocus)
+
+ readonly property bool hasBottom: (nActions ? nActions.count > 0 : false) || nHasInlineReply
+
+ signal actionInvoked(int id, string identifier)
+ signal replySent(int id, string text)
+ signal dismissed(int id)
+ signal activated(int id)
+ signal hideRequested(int id)
+
+ function formatTimeAgo(timestamp) {
+ const now = new Date();
+ const diffMs = now - timestamp;
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+
+ if (diffMins < 1) return "Just now";
+ if (diffMins < 60) return diffMins + "m ago";
+ if (diffHours < 24) return diffHours + "h ago";
+ return Qt.formatTime(timestamp, "h:mm p");
+ }
+
+ width: ListView.view ? ListView.view.width : 400
+ height: contentColumn.implicitHeight + 32
+ Behavior on height { NumberAnimation { duration: 200; easing.type: Easing.OutSine } }
+
+ Squircle {
+ anchors.fill: parent
+ cornerRadius: 16
+ fillColor: Theme.surface
+ strokeColor: Theme.border
+ strokeWidth: 1
+ }
+
+ MouseArea {
+ id: cardHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: nClickable ? Qt.PointingHandCursor : Qt.ArrowCursor
+ onClicked: {
+ if (nClickable) card.activated(nId)
+ }
+ }
+
+ ColumnLayout {
+ id: contentColumn
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ margins: 16
+ }
+ spacing: 12
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 12
+ Layout.alignment: Qt.AlignTop
+
+ // LEFT: Icon
+ Item {
+ width: 44
+ height: 44
+ Layout.alignment: Qt.AlignVCenter
+
+ Squircle {
+ anchors.fill: parent
+ cornerRadius: 10
+ fillColor: Theme.surfaceLighter
+ }
+
+ Image {
+ visible: nAppIcon !== ""
+ anchors.centerIn: parent
+ width: 30; height: 30
+ source: nAppIcon
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ }
+ }
+
+ // MIDDLE: Text
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignTop
+ spacing: 2
+
+ RowLayout {
+ Layout.fillWidth: true
+ Text {
+ text: nAppName
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.Medium
+ }
+ opacity: 0.8
+ elide: Text.ElideRight
+ Layout.fillWidth: true
+ }
+ Text {
+ text: card.formatTimeAgo(nTimestamp)
+ color: Theme.text
+ opacity: 0.5
+ font {
+ family: Theme.mainFont
+ pixelSize: 11
+ }
+
+ Timer {
+ interval: 60000
+ running: true
+ repeat: true
+ onTriggered: parent.text = card.formatTimeAgo(nTimestamp)
+ }
+ }
+ }
+
+ Text {
+ visible: nSummary !== ""
+ Layout.fillWidth: true
+ text: nSummary
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 14
+ weight: Font.DemiBold
+ }
+ wrapMode: Text.WordWrap
+ maximumLineCount: 2
+ elide: Text.ElideRight
+ }
+
+ Text {
+ visible: nBody !== ""
+ Layout.fillWidth: true
+ text: nBody
+ color: Theme.text
+ opacity: 0.85
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ }
+ wrapMode: Text.WordWrap
+ maximumLineCount: 3
+ elide: Text.ElideRight
+ textFormat: Text.StyledText
+ }
+ }
+
+ // RIGHT: Image
+ Item {
+ visible: nImage !== ""
+ width: visible ? 44 : 0
+ height: 44
+ Layout.alignment: Qt.AlignTop
+
+ Squircle {
+ id: mediaMask
+ anchors.fill: parent
+ cornerRadius: 8
+ fillColor: Theme.text
+ visible: false
+ layer.enabled: true
+ layer.smooth: true
+ }
+
+ Image {
+ anchors.fill: parent
+ source: nImage
+ fillMode: Image.PreserveAspectCrop
+ smooth: true
+ asynchronous: true
+ layer.enabled: true
+ layer.effect: MultiEffect {
+ maskEnabled: true
+ maskSource: mediaMask
+ maskThresholdMin: 0.5
+ }
+ }
+ }
+ }
+
+ ColumnLayout {
+ id: bottomStack
+ Layout.fillWidth: true
+ visible: card.hasBottom
+ spacing: 8
+ Layout.leftMargin: 56 // Align with text
+
+ RowLayout {
+ visible: nActions && nActions.count > 0
+ Layout.fillWidth: true
+ spacing: 8
+
+ Repeater {
+ model: nActions
+ delegate: Item {
+ id: actionBtn
+ required property int index
+ required property string identifier
+ required property string text
+
+ Layout.fillWidth: true
+ Layout.preferredHeight: 28
+
+ Squircle {
+ anchors.fill: parent
+ cornerRadius: 6
+ fillColor: actionHover.containsMouse ? Theme.surfaceHover : Theme.surfaceLighter
+ scale: actionHover.pressed ? 0.95 : 1.0
+ Behavior on scale { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } }
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: actionBtn.text
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.Medium
+ }
+ }
+
+ MouseArea {
+ id: actionHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: card.actionInvoked(card.nId, actionBtn.identifier)
+ }
+ }
+ }
+ }
+
+ Item {
+ visible: nHasInlineReply
+ Layout.fillWidth: true
+ Layout.preferredHeight: 30
+
+ Squircle {
+ anchors.fill: parent
+ cornerRadius: 6
+ fillColor: Theme.surfaceLighter
+ }
+
+ TextInput {
+ id: replyInput
+ anchors {
+ left: parent.left; leftMargin: 10
+ right: sendBtn.left; rightMargin: 6
+ top: parent.top; bottom: parent.bottom
+ }
+ verticalAlignment: TextInput.AlignVCenter
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ }
+ clip: true
+ activeFocusOnTab: true
+
+ Text {
+ visible: replyInput.text === "" && !replyInput.activeFocus
+ anchors.fill: parent
+ verticalAlignment: Text.AlignVCenter
+ text: nReplyPlaceholder
+ color: Theme.text
+ opacity: 0.5
+ font: replyInput.font
+ }
+
+ Keys.onReturnPressed: if (text.length > 0) { card.replySent(card.nId, text); text = "" }
+ Keys.onEnterPressed: if (text.length > 0) { card.replySent(card.nId, text); text = "" }
+ }
+
+ Item {
+ id: sendBtn
+ anchors {
+ right: parent.right; rightMargin: 4
+ verticalCenter: parent.verticalCenter
+ }
+ width: 22; height: 22
+ opacity: replyInput.text.length > 0 ? 1.0 : 0.4
+
+ Rectangle {
+ anchors.fill: parent
+ radius: 4
+ color: Theme.text
+ }
+
+ Text {
+ anchors.centerIn: parent
+ text: "↑"
+ color: Theme.bg
+ font.pixelSize: 14
+ font.weight: Font.DemiBold
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: replyInput.text.length > 0
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ card.replySent(card.nId, replyInput.text)
+ replyInput.text = ""
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Close button — top-left, visible on hover
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ anchors {
+ top: parent.top
+ left: parent.left
+ topMargin: -6
+ leftMargin: -6
+ }
+ color: Theme.surfaceLighter
+ border.color: Theme.border
+ border.width: 1
+ opacity: card.hovered ? 1.0 : 0.0
+ Behavior on opacity { NumberAnimation { duration: 150 } }
+
+ Item {
+ anchors.centerIn: parent
+ width: 8
+ height: 8
+
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.width * Math.SQRT2
+ height: 1.2
+ radius: 0.6
+ color: Theme.text
+ rotation: 45
+ antialiasing: true
+ }
+ Rectangle {
+ anchors.centerIn: parent
+ width: parent.width * Math.SQRT2
+ height: 1.2
+ radius: 0.6
+ color: Theme.text
+ rotation: -45
+ antialiasing: true
+ }
+ }
+
+ MouseArea {
+ id: closeHover
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: card.dismissed(card.nId)
+ }
+ }
+
+ Timer {
+ id: hideTimer
+ interval: nTimeout
+ running: !nResident
+ onTriggered: card.hideRequested(nId)
+ }
+
+ Connections {
+ target: card
+ function onHoveredChanged() {
+ if (!nResident) {
+ if (card.hovered) hideTimer.stop()
+ else hideTimer.restart()
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/modules/system/quickshell/NotificationPopupList.qml b/modules/system/quickshell/NotificationPopupList.qml
new file mode 100644
index 0000000..6b9280b
--- /dev/null
+++ b/modules/system/quickshell/NotificationPopupList.qml
@@ -0,0 +1,75 @@
+import QtQuick
+import Quickshell
+import Quickshell.Wayland
+
+PanelWindow {
+ id: root
+ property var popupModel
+
+ signal actionInvoked(int id, string identifier)
+ signal replySent(int id, string text)
+ signal dismissed(int id)
+ signal activated(int id)
+ signal hideRequested(int id)
+
+ visible: popupList.count > 0
+
+ anchors { top: true; right: true }
+ WlrLayershell.margins.top: Theme.barHeight
+ exclusionMode: ExclusionMode.Ignore
+ color: "transparent"
+
+ readonly property int edgeMargin: 12
+ readonly property int animationSafeMargin: 80
+ readonly property int popupWidth: 400
+
+ implicitWidth: popupWidth + edgeMargin * 2 + animationSafeMargin
+ implicitHeight: popupList.contentHeight + edgeMargin * 2
+
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
+
+ ListView {
+ id: popupList
+ anchors {
+ top: parent.top
+ right: parent.right
+ topMargin: edgeMargin
+ rightMargin: edgeMargin
+ }
+
+ width: popupWidth
+ height: contentHeight
+ spacing: 8
+ model: root.popupModel
+ interactive: false
+ clip: false
+
+ delegate: NotificationCard {
+ onActionInvoked: (id, identifier) => root.actionInvoked(id, identifier)
+ onReplySent: (id, text) => root.replySent(id, text)
+ onDismissed: id => root.dismissed(id)
+ onActivated: id => root.activated(id)
+ onHideRequested: id => root.hideRequested(id)
+ }
+
+ add: Transition {
+ ParallelAnimation {
+ NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 250; easing.type: Easing.OutSine }
+ NumberAnimation { property: "x"; from: popupWidth + edgeMargin; duration: 350; easing.type: Easing.OutBack }
+ }
+ }
+
+ remove: Transition {
+ ParallelAnimation {
+ NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 200; easing.type: Easing.InSine }
+ NumberAnimation { property: "x"; to: popupWidth + edgeMargin; duration: 200; easing.type: Easing.InSine }
+ }
+ }
+
+ displaced: Transition {
+ NumberAnimation { properties: "x,y"; duration: 250; easing.type: Easing.OutSine }
+ }
+ }
+}
+
+
diff --git a/modules/system/quickshell/Notifications.qml b/modules/system/quickshell/Notifications.qml
new file mode 100644
index 0000000..d17e903
--- /dev/null
+++ b/modules/system/quickshell/Notifications.qml
@@ -0,0 +1,149 @@
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Services.Notifications
+import QtQuick
+
+Scope {
+ id: scope
+
+ readonly property int defaultTimeout: 5000
+
+ property int nextId: 0
+ property var liveNotifs: ({})
+
+ ListModel { id: popupModel }
+
+ function removeNotificationData(id) {
+ for (let i = 0; i < popupModel.count; i++) {
+ if (popupModel.get(i).nId === id) {
+ popupModel.remove(i)
+ break
+ }
+ }
+ delete liveNotifs[id]
+ }
+
+ function dismissExplicitly(id) {
+ const n = liveNotifs[id]
+ if (n) n.dismiss()
+ removeNotificationData(id)
+ }
+
+ function hidePopup(id) {
+ for (let i = 0; i < popupModel.count; i++) {
+ if (popupModel.get(i).nId === id) {
+ popupModel.remove(i)
+ break
+ }
+ }
+ }
+
+ function activateById(id) {
+ const n = liveNotifs[id]
+ if (!n) return
+
+ let invoked = false
+ for (const action of n.actions) {
+ if (action.identifier === "default") {
+ action.invoke()
+ invoked = true
+ break
+ }
+ }
+ if (!invoked && n.desktopEntry) {
+ Quickshell.execDetached(["gtk-launch", n.desktopEntry])
+ }
+ dismissExplicitly(id)
+ }
+
+ function invokeAction(id, identifier) {
+ const n = liveNotifs[id]
+ if (n) {
+ for (const action of n.actions) {
+ if (action.identifier === identifier) {
+ action.invoke()
+ break
+ }
+ }
+ }
+ dismissExplicitly(id)
+ }
+
+ function sendReply(id, text) {
+ const n = liveNotifs[id]
+ if (n && n.hasInlineReply) n.sendInlineReply(text)
+ dismissExplicitly(id)
+ }
+
+ NotificationServer {
+ keepOnReload: false
+ actionsSupported: true
+ actionIconsSupported: true
+ bodySupported: true
+ bodyMarkupSupported: true
+ bodyImagesSupported: true
+ imageSupported: true
+ inlineReplySupported: true
+ persistenceSupported: true
+
+ onNotification: notif => {
+ notif.tracked = true
+
+ const id = nextId
+ nextId = nextId + 1
+ liveNotifs[id] = notif
+
+ const acts = []
+ let hasDefault = false
+ for (const a of notif.actions) {
+ if (a.identifier === "default") hasDefault = true
+ else acts.push({ identifier: a.identifier, text: a.text })
+ }
+
+ notif.closed.connect(() => removeNotificationData(id))
+
+ let formattedAppIcon = notif.appIcon || ""
+ if (formattedAppIcon !== "") {
+ if (formattedAppIcon.startsWith("file://")) {
+ } else if (formattedAppIcon.startsWith("/")) {
+ formattedAppIcon = "file://" + formattedAppIcon
+ } else {
+ formattedAppIcon = `image://icon/${formattedAppIcon}`
+ }
+ }
+
+ let formattedImage = notif.image || ""
+ if (formattedImage !== "" && formattedImage.startsWith("/")) {
+ formattedImage = "file://" + formattedImage
+ }
+
+ const data = {
+ nId: id,
+ nSummary: notif.summary,
+ nBody: notif.body,
+ nAppName: notif.appName || "Notification",
+ nAppIcon: formattedAppIcon,
+ nImage: formattedImage,
+ nTimestamp: new Date(),
+ nTimeout: notif.expireTimeout > 0 ? notif.expireTimeout : defaultTimeout,
+ nActions: acts,
+ nClickable: hasDefault || (notif.desktopEntry || "") !== "",
+ nHasInlineReply: notif.hasInlineReply || false,
+ nReplyPlaceholder: notif.inlineReplyPlaceholder || "Reply",
+ nResident: notif.resident || false
+ }
+
+ popupModel.insert(0, data)
+ }
+ }
+
+ NotificationPopupList {
+ popupModel: scope.popupModel
+ onActionInvoked: (id, identifier) => scope.invokeAction(id, identifier)
+ onReplySent: (id, text) => scope.sendReply(id, text)
+ onDismissed: id => scope.dismissExplicitly(id)
+ onActivated: id => scope.activateById(id)
+ onHideRequested: id => scope.hidePopup(id)
+ }
+}
+
diff --git a/modules/system/quickshell/PillSlider.qml b/modules/system/quickshell/PillSlider.qml
new file mode 100644
index 0000000..5330ef8
--- /dev/null
+++ b/modules/system/quickshell/PillSlider.qml
@@ -0,0 +1,77 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Effects
+import Quickshell
+
+Slider {
+ id: root
+
+ property string icon: ""
+ property color colorTrack: Theme.sliderTrack
+ property color colorProgress: Theme.accent
+ property color colorHandle: "#FFFFFF"
+ property color iconColor: Theme.iconDefault
+
+ implicitHeight: 24
+ padding: 0
+
+ background: Rectangle {
+ width: root.width
+ height: root.height
+ radius: height / 2
+ color: root.colorTrack
+
+ clip: true
+
+ Rectangle {
+ width: root.handle.x + (root.handle.width / 2)
+ height: parent.height
+ color: root.colorProgress
+ }
+
+ Image {
+ id: iconImage
+ visible: root.icon !== ""
+ anchors.left: parent.left
+ anchors.leftMargin: 6
+ anchors.verticalCenter: parent.verticalCenter
+ source: Quickshell.iconPath(root.icon)
+ sourceSize: Qt.size(14, 14)
+ width: 14
+ height: 14
+ }
+
+ MultiEffect {
+ source: iconImage
+ anchors.fill: iconImage
+ colorizationColor: root.iconColor
+ colorization: 1.0
+ }
+ }
+
+ handle: Item {
+ x: root.visualPosition * (root.availableWidth - width)
+ y: root.topPadding + root.availableHeight / 2 - height / 2
+
+ width: root.height
+ height: root.height
+
+ Rectangle {
+ id: handleCircle
+ anchors.fill: parent
+ radius: width / 2
+ color: root.colorHandle
+ border.width: 1
+ border.color: "#1A000000"
+ }
+
+ MultiEffect {
+ source: handleCircle
+ anchors.fill: handleCircle
+ shadowEnabled: true
+ shadowBlur: 1.0
+ shadowColor: "#4D000000"
+ shadowVerticalOffset: 1
+ }
+ }
+}
diff --git a/modules/system/quickshell/Polkit.qml b/modules/system/quickshell/Polkit.qml
new file mode 100644
index 0000000..4ac8bd1
--- /dev/null
+++ b/modules/system/quickshell/Polkit.qml
@@ -0,0 +1,222 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Services.Polkit
+
+PanelWindow {
+ id: root
+
+ PolkitAgent {
+ id: agent
+
+ Component.onCompleted: console.log("PolkitAgent status: " + (isRegistered ? "Registered" : "Not Registered"))
+ onIsRegisteredChanged: console.log("PolkitAgent registration changed: " + isRegistered)
+
+ onIsActiveChanged: {
+ console.log("PolkitAgent active state: " + isActive)
+ if (isActive) {
+ root.visible = true;
+ passwordInput.text = "";
+ Qt.callLater(() => passwordInput.forceActiveFocus());
+ } else {
+ root.visible = false;
+ }
+ }
+ }
+
+ visible: false
+ color: Theme.transparent
+ anchors {
+ top: true
+ bottom: true
+ left: true
+ right: true
+ }
+ exclusiveZone: 0
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: visible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
+
+ Rectangle {
+ anchors.fill: parent
+ color: Theme.scrim
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ if (agent.flow) {
+ agent.flow.cancelAuthenticationRequest();
+ }
+ }
+ }
+ }
+
+ Squircle {
+ id: dialog
+ anchors.centerIn: parent
+ width: 480
+ height: Math.max(220, layout.implicitHeight + 40)
+ fillColor: Theme.bg
+ strokeColor: Theme.border
+ strokeWidth: 1
+ cornerRadius: 12
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: (mouse) => mouse.accepted = true
+ }
+
+ RowLayout {
+ id: layout
+ anchors.fill: parent
+ anchors.margins: 20
+ spacing: 16
+
+ Item {
+ Layout.alignment: Qt.AlignTop
+ Layout.preferredWidth: 64
+ Layout.preferredHeight: 64
+
+ Image {
+ anchors.fill: parent
+ source: agent.flow ? Quickshell.iconPath(agent.flow.iconName || "dialog-password-symbolic") : ""
+ fillMode: Image.PreserveAspectFit
+ smooth: true
+ mipmap: true
+ sourceSize: Qt.size(64, 64)
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 12
+
+ Text {
+ Layout.fillWidth: true
+ text: agent.flow ? agent.flow.message : ""
+ color: Theme.text
+ font.family: Theme.mainFont
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ wrapMode: Text.WordWrap
+ }
+
+ Text {
+ visible: agent.flow && agent.flow.supplementaryMessage !== ""
+ Layout.fillWidth: true
+ text: agent.flow ? agent.flow.supplementaryMessage : ""
+ color: (agent.flow && agent.flow.supplementaryIsError) ? Theme.destructive : Theme.textDim
+ font.family: Theme.mainFont
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+
+ GridLayout {
+ Layout.fillWidth: true
+ columns: 2
+ rowSpacing: 8
+ columnSpacing: 8
+ visible: agent.flow && agent.flow.isResponseRequired
+
+ Text {
+ text: agent.flow ? agent.flow.inputPrompt : "Password:"
+ color: Theme.text
+ font.family: Theme.mainFont
+ font.pixelSize: 12
+ Layout.alignment: Qt.AlignRight | Qt.AlignVCenter
+ }
+
+ TextField {
+ id: passwordInput
+ Layout.fillWidth: true
+ Layout.preferredHeight: 28
+ font.family: Theme.mainFont
+ font.pixelSize: 12
+ color: Theme.text
+ echoMode: (agent.flow && agent.flow.responseVisible) ? TextInput.Normal : TextInput.Password
+
+ background: Rectangle {
+ color: Theme.surface
+ radius: 4
+ border.color: passwordInput.activeFocus ? Theme.accent : Theme.border
+ border.width: passwordInput.activeFocus ? 2 : 1
+ }
+
+ onAccepted: {
+ if (agent.flow) {
+ agent.flow.submit(passwordInput.text);
+ passwordInput.text = "";
+ }
+ }
+ }
+ }
+
+ Item { Layout.preferredHeight: 4 }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ Item { Layout.fillWidth: true }
+
+ Button {
+ text: "Cancel"
+
+ contentItem: Text {
+ text: parent.text
+ color: Theme.text
+ font.family: Theme.mainFont
+ font.pixelSize: 13
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ implicitWidth: 80
+ implicitHeight: 28
+ color: parent.hovered ? Theme.surfaceHover : Theme.surface
+ radius: 6
+ border.color: Theme.border
+ border.width: 1
+ }
+
+ onClicked: {
+ if (agent.flow) {
+ agent.flow.cancelAuthenticationRequest();
+ }
+ }
+ }
+
+ Button {
+ enabled: agent.flow && (!agent.flow.isResponseRequired || passwordInput.text !== "")
+ text: "Authenticate"
+
+ contentItem: Text {
+ text: parent.text
+ color: enabled ? Theme.text : Theme.textDisabled
+ font.pixelSize: 13
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ implicitWidth: 100
+ implicitHeight: 28
+ color: parent.enabled ? (parent.hovered ? Theme.accentHover : Theme.accent) : Theme.surface
+ radius: 6
+ }
+
+ onClicked: {
+ if (agent.flow) {
+ agent.flow.submit(passwordInput.text);
+ passwordInput.text = "";
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/PopupCard.qml b/modules/system/quickshell/PopupCard.qml
new file mode 100644
index 0000000..6563877
--- /dev/null
+++ b/modules/system/quickshell/PopupCard.qml
@@ -0,0 +1,42 @@
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+
+Squircle {
+ id: root
+
+ readonly property int maxHeight: 440
+ property int margins: 16
+ default property alias content: contentCol.data
+
+ width: 320
+ height: Math.min(Math.max(contentCol.implicitHeight + 2 * margins, 80), maxHeight)
+
+ Behavior on height {
+ SpringAnimation { spring: 3; damping: 0.25 }
+ }
+
+ fillColor: Theme.bg
+ strokeColor: Theme.border
+ strokeWidth: 1
+ cornerRadius: 20
+ clip: true
+
+ ScrollView {
+ anchors {
+ fill: parent
+ margins: root.margins
+ }
+ clip: true
+ contentWidth: availableWidth
+ ScrollBar.vertical.policy: contentCol.implicitHeight > height
+ ? ScrollBar.AsNeeded
+ : ScrollBar.AlwaysOff
+
+ ColumnLayout {
+ id: contentCol
+ width: parent.width
+ spacing: 12
+ }
+ }
+}
diff --git a/modules/system/quickshell/SliderBox.qml b/modules/system/quickshell/SliderBox.qml
new file mode 100644
index 0000000..59b994d
--- /dev/null
+++ b/modules/system/quickshell/SliderBox.qml
@@ -0,0 +1,52 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+
+Squircle {
+ id: root
+ property string label: ""
+ property string icon: ""
+ property real value: 0
+ signal moved(real val)
+
+ Layout.fillWidth: true
+ height: 64
+ cornerRadius: 16
+ fillColor: hoverArea.containsMouse ? Theme.surfaceHover : Theme.surface
+
+ MouseArea {
+ id: hoverArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ }
+
+ ColumnLayout {
+ anchors.fill: parent
+ anchors.margins: 12
+ spacing: 4
+
+ RowLayout {
+ Layout.fillWidth: true
+ Text {
+ text: root.label
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.DemiBold
+ }
+ Layout.leftMargin: 2
+ }
+ Item { Layout.fillWidth: true }
+ }
+
+ PillSlider {
+ id: slider
+ Layout.fillWidth: true
+ icon: root.icon
+ value: root.value
+ onMoved: root.moved(value)
+ }
+ }
+}
diff --git a/modules/system/quickshell/Squircle.qml b/modules/system/quickshell/Squircle.qml
new file mode 100644
index 0000000..1d505c9
--- /dev/null
+++ b/modules/system/quickshell/Squircle.qml
@@ -0,0 +1,12 @@
+import QtQuick
+
+ShaderEffect {
+ id: root
+
+ property color fillColor: "transparent"
+ property color strokeColor: "transparent"
+ property real strokeWidth: 0
+ property real cornerRadius: 12
+
+ fragmentShader: "squircle.qsb"
+}
diff --git a/modules/system/quickshell/Theme.qml b/modules/system/quickshell/Theme.qml
new file mode 100644
index 0000000..f409f98
--- /dev/null
+++ b/modules/system/quickshell/Theme.qml
@@ -0,0 +1,48 @@
+import QtQuick
+
+pragma Singleton
+
+QtObject {
+ // Basics
+ readonly property color bg: "#33000000" // More translucent for macOS 18 style
+ readonly property color barBg: "#66000000"
+ readonly property color surface: "#4DFFFFFF" // Semi-transparent white/gray
+ readonly property color surfaceHover: "#66FFFFFF"
+ readonly property color surfaceLighter: "#80FFFFFF"
+ readonly property color border: "#1AFFFFFF"
+
+ // Accents
+ readonly property color accent: "#007AFF" // macOS Blue
+ readonly property color accentHover: "#0066CC"
+ readonly property color destructive: "#FF3B30"
+ readonly property color focus: "#007AFF"
+
+ // Text
+ readonly property color text: "#FFFFFF"
+ readonly property color textDim: "#EBEBEB"
+ readonly property color textMuted: "#C6C6C6"
+ readonly property color textDisabled: "#999999"
+ readonly property color textPlaceholder: "#808080"
+ readonly property color textLight: "#FFFFFF"
+
+ // Icons
+ readonly property color iconDefault: "#FFFFFF"
+ readonly property color iconActive: "#007AFF"
+
+ // Components
+ readonly property color sliderTrack: "#33FFFFFF"
+ readonly property color sliderHandle: "#FFFFFF"
+ readonly property color sliderOutline: "#1A000000"
+
+ // Transparency Helpers
+ readonly property color scrim: "#80000000"
+ readonly property color transparent: "transparent"
+
+ // Layout
+ readonly property int barHeight: 32 // Slightly taller for macOS look
+ readonly property int popupGap: 8
+
+ // Fonts
+ readonly property string mainFont: "Inter"
+ readonly property string monoFont: "JetBrains Mono"
+}
diff --git a/modules/system/quickshell/ThinSlider.qml b/modules/system/quickshell/ThinSlider.qml
new file mode 100644
index 0000000..61a9ce8
--- /dev/null
+++ b/modules/system/quickshell/ThinSlider.qml
@@ -0,0 +1,40 @@
+import QtQuick
+import QtQuick.Controls
+
+Slider {
+ id: root
+
+ readonly property color colorTrack: Theme.sliderTrack
+ readonly property color colorProgress: Theme.accent
+ readonly property color colorHandle: Theme.sliderHandle
+
+ implicitHeight: 14
+ padding: 0
+
+ background: Rectangle {
+ x: root.leftPadding
+ y: root.topPadding + root.availableHeight / 2 - height / 2
+ implicitWidth: 200
+ implicitHeight: 2
+ width: root.availableWidth
+ height: implicitHeight
+ radius: height / 2
+ color: root.colorTrack
+
+ Rectangle {
+ width: root.handle.x + (root.handle.width / 2)
+ height: parent.height
+ color: root.colorProgress
+ radius: height / 2
+ }
+ }
+
+ handle: Rectangle {
+ x: root.leftPadding + root.visualPosition * (root.availableWidth - width)
+ y: root.topPadding + root.availableHeight / 2 - height / 2
+ implicitWidth: 2
+ implicitHeight: 10
+ radius: width / 2
+ color: root.colorHandle
+ }
+}
diff --git a/modules/system/quickshell/Toggle.qml b/modules/system/quickshell/Toggle.qml
new file mode 100644
index 0000000..76eb36c
--- /dev/null
+++ b/modules/system/quickshell/Toggle.qml
@@ -0,0 +1,31 @@
+import QtQuick
+
+// iOS-style on/off switch. `checked` is the visual state; the parent
+// owns truth and handles `toggled` by flipping it. Uses Item.enabled
+// for the disabled visual + input gating.
+Rectangle {
+ id: root
+ property bool checked: false
+ signal toggled()
+
+ width: 40
+ height: 22
+ radius: 11
+ color: checked ? Theme.accent : Theme.sliderTrack
+ opacity: enabled ? 1.0 : 0.4
+
+ Rectangle {
+ width: 18
+ height: 18
+ radius: 9
+ color: Theme.text
+ anchors { verticalCenter: parent.verticalCenter }
+ x: root.checked ? parent.width - width - 2 : 2
+ Behavior on x { NumberAnimation { duration: 150 } }
+ }
+
+ MouseArea {
+ anchors { fill: parent }
+ onClicked: root.toggled()
+ }
+}
diff --git a/modules/system/quickshell/TrayMenu.qml b/modules/system/quickshell/TrayMenu.qml
new file mode 100644
index 0000000..9e2e9f2
--- /dev/null
+++ b/modules/system/quickshell/TrayMenu.qml
@@ -0,0 +1,39 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+
+Scope {
+ id: root
+ property var menuItem: null
+ property var parentWindow
+ property var anchorItem
+ property bool active: false
+
+ function open(item) {
+ anchorItem = item
+ active = true
+ menuAnchor.open()
+ }
+
+ function close() {
+ active = false
+ menuAnchor.close()
+ }
+
+ QsMenuAnchor {
+ id: menuAnchor
+ menu: root.menuItem
+
+ anchor.window: root.parentWindow
+ anchor.item: root.anchorItem
+ anchor.edges: Edges.Bottom
+ anchor.gravity: Edges.Bottom
+ anchor.margins.top: Theme.popupGap
+
+ onVisibleChanged: {
+ if (!visible && root.active) {
+ root.active = false
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/Volume.qml b/modules/system/quickshell/Volume.qml
new file mode 100644
index 0000000..c4d1625
--- /dev/null
+++ b/modules/system/quickshell/Volume.qml
@@ -0,0 +1,206 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Services.Pipewire
+
+Item {
+ id: root
+ width: childrenRect.width
+ height: parent.height
+
+ // Properties bound directly to the Pipewire service
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ readonly property bool muted: sink && sink.audio ? sink.audio.muted : true
+ readonly property real volume: sink && sink.audio ? sink.audio.volume : 0
+
+ // Pipewire nodes only emit property updates while tracked.
+ PwObjectTracker {
+ objects: root.sink ? [root.sink] : []
+ }
+
+ function setVolume(value) {
+ if (sink?.ready && sink?.audio) {
+ sink.audio.muted = false;
+ sink.audio.volume = value;
+ } else {
+ // Fallback for unbound nodes
+ Quickshell.execDetached(["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", value.toFixed(2)]);
+ }
+ }
+
+ function toggleMute() {
+ if (sink?.ready && sink?.audio) {
+ sink.audio.muted = !sink.audio.muted;
+ } else {
+ Quickshell.execDetached(["wpctl", "set-mute", "@DEFAULT_AUDIO_SINK@", "toggle"]);
+ }
+ }
+
+ function getDeviceIcon(node) {
+ return node?.properties?.["device.icon-name"] ?? "audio-card"
+ }
+
+ Row {
+ id: triggerRow
+ anchors {
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 4
+
+ Image {
+ width: 20
+ height: 20
+ source: Quickshell.iconPath(root.getDeviceIcon(root.sink) + "-symbolic")
+ sourceSize: Qt.size(width, height)
+ smooth: true
+ mipmap: true
+ opacity: root.muted ? 0.5 : 1.0
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ GlobalState.toggle("Volume")
+ } else if (mouse.button === Qt.RightButton) {
+ root.toggleMute()
+ }
+ }
+ onWheel: wheel => {
+ const step = 0.05
+ const next = wheel.angleDelta.y > 0 ? root.volume + step : root.volume - step
+ root.setVolume(Math.max(0.0, Math.min(1.0, next)))
+ }
+ }
+
+ PopupWindow {
+ id: popup
+ visible: GlobalState.activePopup === "Volume"
+ grabFocus: true
+ implicitWidth: bgRect.width
+ implicitHeight: bgRect.height
+
+ anchor {
+ window: barWindow
+ item: root
+ edges: Edges.Bottom
+ gravity: Edges.Bottom
+ margins.top: Theme.popupGap
+ }
+
+ color: "transparent"
+
+ onVisibleChanged: {
+ if (visible) anchor.updateAnchor()
+ }
+
+ Squircle {
+ id: bgRect
+ width: 260
+ height: contentCol.height + 24
+ fillColor: Theme.bg
+ strokeColor: Theme.border
+ strokeWidth: 1
+ cornerRadius: 8
+
+ Column {
+ id: contentCol
+ anchors {
+ top: parent.top
+ left: parent.left
+ right: parent.right
+ margins: 12
+ }
+ spacing: 12
+
+ Text {
+ text: "Sound"
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 14
+ weight: Font.DemiBold
+ }
+ }
+
+ PillSlider {
+ id: volumeSlider
+ width: parent.width
+ value: root.volume
+ onMoved: root.setVolume(value)
+
+ Binding {
+ target: volumeSlider
+ property: "value"
+ value: root.volume
+ when: !volumeSlider.pressed
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Theme.border }
+
+ Text {
+ text: "Output Devices"
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.DemiBold
+ }
+ padding: 4
+ }
+
+ Repeater {
+ model: Pipewire.nodes
+ delegate: Item {
+ width: parent.width
+ property bool isOutput: modelData && modelData.isSink && !modelData.isStream && modelData.name !== "Dummy-Driver"
+ visible: isOutput
+ height: isOutput ? 40 : 0
+
+ Squircle {
+ anchors.fill: parent
+ fillColor: mouseArea.containsMouse ? Theme.surfaceLighter : Theme.transparent
+ cornerRadius: 6
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 8
+ spacing: 12
+
+ IconCircle {
+ size: 24
+ source: root.getDeviceIcon(modelData)
+ active: root.sink && root.sink.id === modelData.id
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: modelData.description || modelData.nickname || modelData.name
+ color: (root.sink && root.sink.id === modelData.id) ? Theme.text : Theme.textDim
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ }
+ elide: Text.ElideRight
+ }
+ }
+
+ MouseArea {
+ id: mouseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: {
+ Quickshell.execDetached(["wpctl", "set-default", modelData.id.toString()])
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/VolumeOSD.qml b/modules/system/quickshell/VolumeOSD.qml
new file mode 100644
index 0000000..8efe305
--- /dev/null
+++ b/modules/system/quickshell/VolumeOSD.qml
@@ -0,0 +1,90 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Services.Pipewire
+import Quickshell.Wayland
+
+// Volume OSD that appears on volume change, styled to match the system menus.
+Scope {
+ id: root
+
+ readonly property PwNode sink: Pipewire.defaultAudioSink
+ property bool visible: false
+
+ PwObjectTracker {
+ objects: root.sink ? [root.sink] : []
+ }
+
+ Connections {
+ target: root.sink && root.sink.audio ? root.sink.audio : null
+ ignoreUnknownSignals: true
+
+ function onVolumeChanged() {
+ root.visible = true
+ hideTimer.restart()
+ }
+ function onMutedChanged() {
+ root.visible = true
+ hideTimer.restart()
+ }
+ }
+
+ Timer {
+ id: hideTimer
+ interval: 2000
+ onTriggered: root.visible = false
+ }
+
+ PanelWindow {
+ visible: root.visible
+
+ // Compositor centers horizontally if only bottom anchor is set
+ anchors.bottom: true
+ margins.bottom: 100
+ exclusiveZone: 0
+
+ implicitWidth: 240
+ implicitHeight: 64
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ exclusionMode: ExclusionMode.Ignore
+ color: Theme.transparent
+ mask: Region {} // Pass-through clicks
+
+ Squircle {
+ id: card
+ anchors.fill: parent
+ fillColor: Theme.bg
+ strokeColor: Theme.border
+ strokeWidth: 1
+ cornerRadius: 16
+
+ RowLayout {
+ anchors {
+ fill: parent
+ margins: 16
+ }
+ spacing: 12
+
+ IconCircle {
+ size: 32
+ source: {
+ if (root.sink?.audio?.muted) return "audio-volume-muted"
+ const vol = root.sink?.audio?.volume ?? 0
+ if (vol <= 0) return "audio-volume-low"
+ if (vol <= 0.33) return "audio-volume-low"
+ if (vol <= 0.66) return "audio-volume-medium"
+ return "audio-volume-high"
+ }
+ active: !(root.sink?.audio?.muted ?? true)
+ }
+
+ PillSlider {
+ Layout.fillWidth: true
+ value: root.sink?.audio?.volume ?? 0
+ enabled: false // OSD is for display only
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/Wifi.qml b/modules/system/quickshell/Wifi.qml
new file mode 100644
index 0000000..0ce57a0
--- /dev/null
+++ b/modules/system/quickshell/Wifi.qml
@@ -0,0 +1,397 @@
+import QtQuick
+import QtQuick.Layouts
+import Quickshell
+import Quickshell.Networking
+import Quickshell.Widgets
+
+Item {
+ id: root
+
+ Scope {
+ id: internal
+
+ readonly property var device: {
+ if (!Networking.devices) return null
+ for (const d of Networking.devices.values || []) {
+ if (d && d.scannerEnabled !== undefined) return d
+ }
+ return null
+ }
+
+ readonly property var allNetworks: device?.networks ? device.networks.values : []
+
+ property bool hasKnown: false
+ property bool hasOther: false
+ property var activeNetwork: null
+
+ function updateState() {
+ let known = false
+ let other = false
+ let active = null
+
+ for (const n of internal.allNetworks) {
+ if (n?.connected) active = n
+ if (n?.known) known = true
+ if (n && !n.known && n.name) other = true
+ }
+
+ internal.hasKnown = known
+ internal.hasOther = other
+ internal.activeNetwork = active
+ }
+
+ Connections {
+ target: internal.device ? internal.device.networks : null
+ function onValuesChanged() { internal.updateState() }
+ }
+ }
+
+ function _getWifiIcon(strength) {
+ if (!Networking.wifiEnabled) return "network-wireless-offline-symbolic"
+ if (!internal.activeNetwork && !internal.device?.enabled) return "network-wireless-offline-symbolic"
+
+ const s = strength ?? 0
+ if (s >= 0.75) return "network-wireless-signal-excellent-symbolic"
+ if (s >= 0.50) return "network-wireless-signal-good-symbolic"
+ if (s >= 0.25) return "network-wireless-signal-ok-symbolic"
+ if (s > 0) return "network-wireless-signal-weak-symbolic"
+ return "network-wireless-signal-none-symbolic"
+ }
+
+ function _onNetworkClick(net) {
+ if (!net) return
+ if (net.connected) { net.disconnect(); return }
+ if (net.stateChanging) return
+ if (net.known) {
+ net.connect()
+ } else if ((net.security ?? 0) === 0) {
+ net.connect()
+ } else {
+ pskPrompt.network = net
+ pskPrompt.open(net.name ?? "")
+ }
+ }
+
+ width: childrenRect.width
+ height: parent.height
+
+ Component.onCompleted: internal.updateState()
+
+ Row {
+ anchors {
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 4
+ Image {
+ width: 20
+ height: 20
+ source: Quickshell.iconPath(root._getWifiIcon(internal.activeNetwork?.signalStrength ?? 0))
+ sourceSize: Qt.size(width, height)
+ smooth: true
+ mipmap: true
+ opacity: !Networking.wifiEnabled ? 0.35 : 1.0
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.LeftButton | Qt.RightButton
+ onClicked: mouse => {
+ if (mouse.button === Qt.LeftButton) {
+ GlobalState.toggle("Wifi")
+ } else if (mouse.button === Qt.RightButton) {
+ Networking.wifiEnabled = !Networking.wifiEnabled
+ }
+ }
+ }
+
+ PopupWindow {
+ id: popup
+ visible: GlobalState.activePopup === "Wifi"
+ grabFocus: true
+ implicitWidth: card.width
+ implicitHeight: card.height
+
+ anchor {
+ window: barWindow
+ item: root
+ edges: Edges.Bottom
+ gravity: Edges.Bottom
+ margins.top: Theme.popupGap
+ }
+
+ color: Theme.transparent
+
+ onVisibleChanged: {
+ if (visible) {
+ anchor.updateAnchor()
+ if (internal.device) internal.device.scannerEnabled = true
+ } else if (internal.device?.scannerEnabled) {
+ internal.device.scannerEnabled = false
+ }
+ }
+
+ PopupCard {
+ id: card
+ margins: 16
+
+ // Wi-Fi { Toggle }
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.topMargin: 4
+ Layout.bottomMargin: 4
+ Layout.leftMargin: 8
+ Layout.rightMargin: 8
+ spacing: 8
+
+ Text {
+ text: "Wi-Fi"
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 14
+ weight: Font.DemiBold
+ }
+ }
+
+ Item { Layout.fillWidth: true }
+
+ Toggle {
+ checked: Networking.wifiEnabled
+ enabled: Networking.wifiHardwareEnabled
+ onToggled: Networking.wifiEnabled = !Networking.wifiEnabled
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 1
+ color: Theme.border
+ Layout.topMargin: 4
+ Layout.bottomMargin: 4
+ }
+
+ // Known Network Header
+ Text {
+ visible: internal.hasKnown
+ text: "Known Network"
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.DemiBold
+ }
+ Layout.leftMargin: 8
+ Layout.topMargin: 2
+ Layout.bottomMargin: 2
+ }
+
+ // Known networks
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+ visible: internal.hasKnown
+
+ Repeater {
+ model: internal.device?.networks ?? null
+
+ delegate: Squircle {
+ id: knownItem
+ required property var modelData
+ readonly property bool isConnected: knownItem.modelData?.connected ?? false
+ visible: knownItem.modelData?.known ?? false
+ Layout.fillWidth: true
+ Layout.preferredHeight: visible ? 36 : 0
+ fillColor: knownArea.containsMouse ? Theme.surface : Theme.transparent
+ cornerRadius: 6
+
+ Connections {
+ target: knownItem.modelData
+ function onKnownChanged() { internal.updateState() }
+ function onConnectedChanged() { internal.updateState() }
+ function onNameChanged() { internal.updateState() }
+ function onSignalStrengthChanged() { internal.updateState() }
+ }
+
+ MouseArea {
+ id: knownArea
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: !(knownItem.modelData?.stateChanging ?? false)
+ onClicked: root._onNetworkClick(knownItem.modelData)
+ }
+
+ RowLayout {
+ anchors {
+ fill: parent
+ leftMargin: 8
+ rightMargin: 8
+ }
+ spacing: 12
+
+ IconCircle {
+ size: 24
+ source: root._getWifiIcon(knownItem.modelData?.signalStrength ?? 0)
+ active: knownItem.isConnected
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: knownItem.modelData?.name ?? ""
+ color: Theme.text
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ }
+ elide: Text.ElideRight
+ }
+
+ IconImage {
+ visible: (knownItem.modelData?.security ?? 0) !== 0
+ Layout.preferredWidth: 14
+ Layout.preferredHeight: 14
+ source: Quickshell.iconPath("changes-prevent-symbolic")
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredHeight: 1
+ color: Theme.border
+ Layout.topMargin: 4
+ Layout.bottomMargin: 4
+ }
+
+ // Other Networks Header
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.leftMargin: 8
+ Layout.rightMargin: 8
+ Layout.topMargin: 2
+ Layout.bottomMargin: 2
+
+ Text {
+ text: "Other Networks"
+ color: Theme.textMuted
+ font {
+ family: Theme.mainFont
+ pixelSize: 12
+ weight: Font.DemiBold
+ }
+ Layout.fillWidth: true
+ }
+
+ Image {
+ id: refreshIcon
+ width: 14
+ height: 14
+ source: Quickshell.iconPath("view-refresh-symbolic")
+ sourceSize: Qt.size(width, height)
+ opacity: refreshMouse.containsMouse ? 1.0 : 0.6
+
+ RotationAnimation on rotation {
+ running: internal.device?.scannerEnabled ?? false
+ from: 0
+ to: 360
+ duration: 1000
+ loops: Animation.Infinite
+ }
+
+ MouseArea {
+ id: refreshMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ if (internal.device) {
+ internal.device.scannerEnabled = !internal.device.scannerEnabled
+ }
+ }
+ }
+ }
+ }
+
+ // Other networks list
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 2
+ visible: internal.hasOther
+
+ Repeater {
+ model: internal.device?.networks ?? null
+
+ delegate: Squircle {
+ id: otherItem
+ required property var modelData
+ visible: !(otherItem.modelData?.known ?? true) && (otherItem.modelData?.name ?? "") !== ""
+ Layout.fillWidth: true
+ Layout.preferredHeight: visible ? 36 : 0
+ fillColor: otherArea.containsMouse ? Theme.surface : Theme.transparent
+ cornerRadius: 6
+
+ Connections {
+ target: otherItem.modelData
+ function onKnownChanged() { internal.updateState() }
+ function onNameChanged() { internal.updateState() }
+ function onSignalStrengthChanged() { internal.updateState() }
+ }
+
+ MouseArea {
+ id: otherArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ enabled: !(otherItem.modelData?.stateChanging ?? false)
+ onClicked: root._onNetworkClick(otherItem.modelData)
+ }
+
+ RowLayout {
+ anchors {
+ fill: parent
+ leftMargin: 8
+ rightMargin: 8
+ }
+ spacing: 12
+
+ IconCircle {
+ size: 24
+ source: root._getWifiIcon(otherItem.modelData?.signalStrength ?? 0)
+ active: false
+ }
+
+ Text {
+ Layout.fillWidth: true
+ text: otherItem.modelData?.name ?? ""
+ color: Theme.text
+ opacity: 0.8
+ font {
+ family: Theme.mainFont
+ pixelSize: 13
+ }
+ elide: Text.ElideRight
+ }
+
+ IconImage {
+ visible: (otherItem.modelData?.security ?? 0) !== 0
+ Layout.preferredWidth: 14
+ Layout.preferredHeight: 14
+ source: Quickshell.iconPath("changes-prevent-symbolic")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ WifiPasswordPrompt {
+ id: pskPrompt
+ property var network: null
+ onSubmitted: (text, remember) => {
+ if (network) network.connectWithPsk(text)
+ }
+ }
+}
diff --git a/modules/system/quickshell/WifiPasswordPrompt.qml b/modules/system/quickshell/WifiPasswordPrompt.qml
new file mode 100644
index 0000000..c77c351
--- /dev/null
+++ b/modules/system/quickshell/WifiPasswordPrompt.qml
@@ -0,0 +1,226 @@
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import Quickshell
+import Quickshell.Wayland
+import Quickshell.Hyprland
+
+PanelWindow {
+ id: root
+
+ // Properties
+ property string networkName: ""
+ property string title: "The Wi-Fi network \"" + networkName + "\" requires a password."
+ property string submitLabel: "Join"
+ property string iconSource: "network-wireless-symbolic"
+
+ signal submitted(string text, bool remember)
+ signal cancelled()
+
+ // Methods
+ function open(name) {
+ networkName = name
+ passwordInput.text = ""
+ showPasswordCheck.checked = false
+ rememberNetworkCheck.checked = true
+ visible = true
+ Qt.callLater(() => passwordInput.forceActiveFocus())
+ }
+
+ // Window Configuration
+ visible: false
+ color: Theme.transparent
+ exclusiveZone: 0
+
+ anchors {
+ top: true
+ bottom: true
+ left: true
+ right: true
+ }
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ HyprlandFocusGrab {
+ id: focusGrab
+ windows: [root]
+ active: root.visible
+ }
+
+ // UI Layout
+ Rectangle {
+ anchors.fill: parent
+ color: Theme.scrim
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.AllButtons
+ hoverEnabled: true
+ onWheel: wheel => wheel.accepted = true
+ }
+ }
+
+ Squircle {
+ id: dialog
+ anchors.centerIn: parent
+ width: 480
+ height: Math.max(180, layout.implicitHeight + 40)
+ fillColor: Theme.bg
+ strokeColor: Theme.border
+ strokeWidth: 1
+ cornerRadius: 12
+
+ Keys.onEscapePressed: {
+ root.visible = false
+ root.cancelled()
+ }
+
+ RowLayout {
+ id: layout
+ anchors.fill: parent
+ anchors.margins: 20
+ spacing: 16
+
+ Item {
+ Layout.alignment: Qt.AlignTop
+ Layout.preferredWidth: 64
+ Layout.preferredHeight: 64
+
+ Image {
+ anchors.fill: parent
+ source: Quickshell.iconPath(root.iconSource)
+ fillMode: Image.PreserveAspectFit
+ }
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 8
+
+ Text {
+ Layout.fillWidth: true
+ text: root.title
+ color: Theme.text
+ font.pixelSize: 13
+ font.weight: Font.DemiBold
+ wrapMode: Text.WordWrap
+ }
+
+ GridLayout {
+ Layout.fillWidth: true
+ columns: 2
+ rowSpacing: 8
+ columnSpacing: 8
+
+ Text {
+ text: "Password:"
+ color: Theme.text
+ font.pixelSize: 12
+ Layout.alignment: Qt.AlignRight | Qt.AlignTop
+ Layout.topMargin: 6
+ }
+
+ ColumnLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ TextField {
+ id: passwordInput
+ focus: true
+ Layout.fillWidth: true
+ Layout.preferredHeight: 28
+ font.pixelSize: 12
+ color: Theme.text
+ echoMode: showPasswordCheck.checked ? TextInput.Normal : TextInput.Password
+
+ background: Rectangle {
+ color: Theme.surface
+ radius: 4
+ border.color: passwordInput.activeFocus ? Theme.accent : Theme.border
+ border.width: passwordInput.activeFocus ? 2 : 1
+ }
+
+ onAccepted: {
+ root.submitted(passwordInput.text, rememberNetworkCheck.checked)
+ root.visible = false
+ }
+ }
+
+ CustomCheckBox {
+ id: showPasswordCheck
+ text: "Show password"
+ Layout.fillWidth: true
+ }
+
+ CustomCheckBox {
+ id: rememberNetworkCheck
+ text: "Remember this network"
+ checked: true
+ Layout.fillWidth: true
+ }
+ }
+ }
+
+ Item { Layout.preferredHeight: 4 }
+
+ RowLayout {
+ Layout.fillWidth: true
+ spacing: 8
+
+ Item { Layout.fillWidth: true }
+
+ Button {
+ text: "Cancel"
+
+ contentItem: Text {
+ text: parent.text
+ color: Theme.text
+ font.pixelSize: 13
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ implicitWidth: 80
+ implicitHeight: 28
+ color: parent.hovered ? Theme.surfaceHover : Theme.surface
+ radius: 6
+ border.color: Theme.border
+ border.width: 1
+ }
+
+ onClicked: {
+ root.visible = false
+ root.cancelled()
+ }
+ }
+
+ Button {
+ text: root.submitLabel
+
+ contentItem: Text {
+ text: parent.text
+ color: Theme.text
+ font.pixelSize: 13
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ background: Rectangle {
+ implicitWidth: 80
+ implicitHeight: 28
+ color: parent.hovered ? Theme.accentHover : Theme.accent
+ radius: 6
+ }
+
+ onClicked: {
+ root.submitted(passwordInput.text, rememberNetworkCheck.checked)
+ root.visible = false
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/Workspaces.qml b/modules/system/quickshell/Workspaces.qml
new file mode 100644
index 0000000..b63826d
--- /dev/null
+++ b/modules/system/quickshell/Workspaces.qml
@@ -0,0 +1,99 @@
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+Item {
+ id: root
+
+ implicitWidth: row.implicitWidth
+ implicitHeight: 32
+
+ ListModel { id: workspaceModel }
+
+ function updateWorkspaces(json) {
+ try {
+ const ws = JSON.parse(json)
+ workspaceModel.clear()
+ ws.forEach(w => workspaceModel.append({
+ wsNum: w.num,
+ wsName: w.name,
+ wsFocused: w.focused
+ }))
+ } catch (_) {}
+ }
+
+ Process {
+ id: watcher
+ command: ["swaymsg", "-t", "subscribe", "-m", "[\"workspace\"]"]
+ running: true
+
+ stdout: SplitParser {
+ onRead: _ => {
+ if (!refresher.running) refresher.running = true
+ }
+ }
+ }
+
+ Process {
+ id: refresher
+ command: ["swaymsg", "-t", "get_workspaces", "-r"]
+
+ property string buf: ""
+
+ stdout: SplitParser {
+ onRead: line => refresher.buf += line
+ }
+
+ onRunningChanged: {
+ if (!running && buf !== "") {
+ root.updateWorkspaces(buf)
+ buf = ""
+ }
+ }
+
+ Component.onCompleted: running = true
+ }
+
+ Row {
+ id: row
+ anchors {
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 2
+
+ Repeater {
+ model: workspaceModel
+
+ delegate: Rectangle {
+ required property int wsNum
+ required property string wsName
+ required property bool wsFocused
+
+ width: 26
+ height: 26
+ color: wsFocused ? Theme.focus : Theme.transparent
+ radius: 3
+
+ Text {
+ anchors.centerIn: parent
+ text: wsNum
+ color: Theme.text
+ font.pixelSize: 12
+ }
+
+ Process {
+ id: switcher
+ command: ["swaymsg", "workspace", "number", wsNum.toString()]
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: {
+ switcher.running = false
+ switcher.running = true
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/system/quickshell/pam/password.conf b/modules/system/quickshell/pam/password.conf
new file mode 100644
index 0000000..7e5d75a
--- /dev/null
+++ b/modules/system/quickshell/pam/password.conf
@@ -0,0 +1 @@
+auth required pam_unix.so
diff --git a/modules/system/quickshell/qmldir b/modules/system/quickshell/qmldir
new file mode 100644
index 0000000..1c0341b
--- /dev/null
+++ b/modules/system/quickshell/qmldir
@@ -0,0 +1,33 @@
+Bar Bar.qml
+Background Background.qml
+Bluetooth Bluetooth.qml
+BrightnessService BrightnessService.qml
+ControlCenter ControlCenter.qml
+singleton GlobalState GlobalState.qml
+IconCircle IconCircle.qml
+Media Media.qml
+MusicVisualizer MusicVisualizer.qml
+Notifications Notifications.qml
+NotificationCard NotificationCard.qml
+NotificationPopupList NotificationPopupList.qml
+Polkit Polkit.qml
+PillSlider PillSlider.qml
+PopupCard PopupCard.qml
+Squircle Squircle.qml
+singleton Theme Theme.qml
+ThinSlider ThinSlider.qml
+Toggle Toggle.qml
+TrayMenu TrayMenu.qml
+VolumeOSD VolumeOSD.qml
+Volume Volume.qml
+Wifi Wifi.qml
+WifiPasswordPrompt WifiPasswordPrompt.qml
+Workspaces Workspaces.qml
+CustomCheckBox CustomCheckBox.qml
+MediaCard MediaCard.qml
+ConnectivityBox ConnectivityBox.qml
+ControlTile ControlTile.qml
+SliderBox SliderBox.qml
+Launcher Launcher.qml
+LockContext LockContext.qml
+LockSurface LockSurface.qml
diff --git a/modules/system/quickshell/shell.qml b/modules/system/quickshell/shell.qml
new file mode 100644
index 0000000..a61edad
--- /dev/null
+++ b/modules/system/quickshell/shell.qml
@@ -0,0 +1,68 @@
+//@ pragma UseQApplication
+import Quickshell
+import QtQuick
+import Quickshell.Io
+import Quickshell.Wayland
+
+ShellRoot {
+ Component.onCompleted: {
+ Qt.application.font.family = "Inter"
+ Qt.application.font.hintingPreference = Font.PreferNoHinting
+ Qt.application.font.styleStrategy = Font.NoSubpixelAntialias
+ }
+
+ Variants {
+ model: Quickshell.screens
+
+ Bar {
+ required property var modelData
+ screen: modelData
+ }
+ }
+
+ Variants {
+ model: Quickshell.screens
+
+ Background {
+ required property var modelData
+ screen: modelData
+ }
+ }
+
+ Notifications {}
+
+ VolumeOSD {}
+ Polkit {}
+ Launcher {}
+
+ LockContext {
+ id: lockContext
+ onUnlocked: {
+ sessionLock.locked = false;
+ }
+ }
+
+ WlSessionLock {
+ id: sessionLock
+
+ WlSessionLockSurface {
+ LockSurface {
+ anchors.fill: parent
+ context: lockContext
+ }
+ }
+ }
+
+ IpcHandler {
+ target: "bar"
+ function toggleLauncher() {
+ GlobalState.toggle("Launcher")
+ }
+
+ function lock() {
+ lockContext.reset();
+ sessionLock.locked = true
+ }
+ }
+}
+
diff --git a/modules/system/quickshell/squircle.frag b/modules/system/quickshell/squircle.frag
new file mode 100644
index 0000000..df2477f
--- /dev/null
+++ b/modules/system/quickshell/squircle.frag
@@ -0,0 +1,47 @@
+#version 440
+layout(location = 0) in vec2 qt_TexCoord0;
+layout(location = 0) out vec4 fragColor;
+
+layout(std140, binding = 0) uniform buf {
+ mat4 qt_Matrix;
+ float qt_Opacity;
+ float width;
+ float height;
+ float cornerRadius;
+ float strokeWidth;
+ vec4 fillColor;
+ vec4 strokeColor;
+} ubuf;
+
+float squircleSDF(vec2 p, vec2 size, float r) {
+ vec2 q = abs(p) - size + vec2(r);
+ vec2 cornerSpace = max(q, 0.0);
+
+ float p_norm = pow(cornerSpace.x, 4.5) + pow(cornerSpace.y, 4.5);
+ float cornerDist = pow(p_norm, 1.0 / 4.5);
+
+ return cornerDist + min(max(q.x, q.y), 0.0) - r;
+}
+
+void main() {
+ vec2 halfSize = vec2(ubuf.width, ubuf.height) * 0.5;
+ vec2 p = (qt_TexCoord0 * vec2(ubuf.width, ubuf.height)) - halfSize;
+
+ // Applied the scaling factor mentioned in the original comment
+ float r = ubuf.cornerRadius * 1.5286;
+
+ float dist = squircleSDF(p, halfSize, r);
+ float fwidth_dist = fwidth(dist);
+
+ // Corrected edge parameter order to avoid undefined behavior
+ float alpha = 1.0 - smoothstep(-fwidth_dist, fwidth_dist, dist);
+
+ if (ubuf.strokeWidth > 0.0) {
+ float innerDist = dist + ubuf.strokeWidth;
+ float innerAlpha = 1.0 - smoothstep(-fwidth_dist, fwidth_dist, innerDist);
+ vec4 color = mix(ubuf.strokeColor, ubuf.fillColor, innerAlpha);
+ fragColor = color * alpha * ubuf.qt_Opacity;
+ } else {
+ fragColor = ubuf.fillColor * alpha * ubuf.qt_Opacity;
+ }
+}
diff --git a/modules/system/quickshell/squircle.qsb b/modules/system/quickshell/squircle.qsb
new file mode 100644
index 0000000..b69fb4e
--- /dev/null
+++ b/modules/system/quickshell/squircle.qsb
Binary files differ
diff --git a/modules/system/quickshell/wallpaper.jpg b/modules/system/quickshell/wallpaper.jpg
new file mode 100644
index 0000000..5cd2332
--- /dev/null
+++ b/modules/system/quickshell/wallpaper.jpg
Binary files differ