Krolyxon
Commit a78a1e5

Branches

Refs →

auroraa78a1e5

add wal-control quichshell

Author
krolyxon
Date
2026-10-10 12:04 +0530
Parent
1d0e403
Commit
a78a1e580360bc2c904f8b9b7a340d5b6a9c394c

+1365 −0

16 files changed

  1. M .config/hypr/source/keybinds.lua +3 −0
  2. A .config/quickshell/wal-control/AudioPage.qml +108 −0
  3. A .config/quickshell/wal-control/BarSlider.qml +58 −0
  4. A .config/quickshell/wal-control/Bright.qml +41 −0
  5. A .config/quickshell/wal-control/BtPage.qml +143 −0
  6. A .config/quickshell/wal-control/HomePage.qml +100 −0
  7. A .config/quickshell/wal-control/IconBtn.qml +46 −0
  8. A .config/quickshell/wal-control/ListRow.qml +72 −0
  9. A .config/quickshell/wal-control/MediaCard.qml +152 −0
  10. A .config/quickshell/wal-control/Net.qml +198 −0
  11. A .config/quickshell/wal-control/NetPage.qml +136 −0
  12. A .config/quickshell/wal-control/SectionLabel.qml +17 −0
  13. A .config/quickshell/wal-control/SliderRow.qml +30 −0
  14. A .config/quickshell/wal-control/Theme.qml +131 −0
  15. A .config/quickshell/wal-control/shell.qml +118 −0
  16. A .config/quickshell/wal-control/wal-control.service +12 −0
M .config/hypr/source/keybinds.lua View
@@ -144,3 +144,6 @@ hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
144144 hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
145145 hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
146146 hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
147+
148+-- Quichshell
149+hl.bind(mainMod .. "+ C", hl.dsp.exec_cmd("qs -p ~/.config/quickshell/wal-control ipc call control toggle"))
A .config/quickshell/wal-control/AudioPage.qml View
@@ -0,0 +1,108 @@
1+import QtQuick
2+import QtQuick.Layouts
3+import Quickshell.Services.Pipewire
4+
5+Flickable {
6+ id: flick
7+ clip: true
8+ contentWidth: width
9+ contentHeight: col.implicitHeight
10+ boundsBehavior: Flickable.StopAtBounds
11+
12+ function mediaClass(n) {
13+ return (n.properties && n.properties["media.class"]) ? String(n.properties["media.class"]) : ""
14+ }
15+ function label(n) { return n.description || n.nickname || n.name }
16+
17+ // kind: "sink" | "source" | "stream"
18+ function nodes(kind) {
19+ var l = Pipewire.nodes.values, out = []
20+ for (var i = 0; i < l.length; i++) {
21+ var n = l[i]
22+ if (!n.audio) continue
23+ if (kind === "sink" && !n.isStream && n.isSink) out.push(n)
24+ else if (kind === "source" && !n.isStream && !n.isSink) out.push(n)
25+ else if (kind === "stream" && n.isStream && mediaClass(n).indexOf("Output") >= 0) out.push(n)
26+ }
27+ return out
28+ }
29+ function sinkIcon(n) {
30+ var s = (n.name + " " + n.description).toLowerCase()
31+ return (s.indexOf("bluez") >= 0 || s.indexOf("head") >= 0) ? Theme.iHeadphones : Theme.iSpeaker
32+ }
33+
34+ readonly property var sinks: nodes("sink")
35+ readonly property var sources: nodes("source")
36+ readonly property var streams: nodes("stream")
37+
38+ ColumnLayout {
39+ id: col
40+ width: flick.width
41+ spacing: 4
42+
43+ SectionLabel { text: "output device" }
44+ Repeater {
45+ model: flick.sinks
46+ delegate: ListRow {
47+ required property var modelData
48+ icon: flick.sinkIcon(modelData)
49+ title: flick.label(modelData)
50+ subtitle: current ? "default output" : ""
51+ current: Pipewire.defaultAudioSink === modelData
52+ trailing: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : ""
53+ onClicked: Pipewire.preferredDefaultAudioSink = modelData
54+ }
55+ }
56+
57+ SectionLabel { text: "input device"; Layout.topMargin: 10 }
58+ Repeater {
59+ model: flick.sources
60+ delegate: ListRow {
61+ required property var modelData
62+ icon: Theme.iMic
63+ title: flick.label(modelData)
64+ subtitle: current ? "default input" : ""
65+ current: Pipewire.defaultAudioSource === modelData
66+ trailing: modelData.audio ? Math.round(modelData.audio.volume * 100) + "%" : ""
67+ onClicked: Pipewire.preferredDefaultAudioSource = modelData
68+ }
69+ }
70+
71+ SectionLabel { text: "applications"; Layout.topMargin: 10 }
72+ Text {
73+ visible: flick.streams.length === 0
74+ text: "no apps playing audio"
75+ color: Theme.dim
76+ font.family: Theme.font
77+ font.pixelSize: Theme.fontSize - 1
78+ Layout.leftMargin: 10
79+ }
80+ Repeater {
81+ model: flick.streams
82+ delegate: ColumnLayout {
83+ required property var modelData
84+ Layout.fillWidth: true
85+ spacing: 0
86+ Text {
87+ Layout.leftMargin: 4
88+ Layout.fillWidth: true
89+ elide: Text.ElideRight
90+ text: (modelData.properties && modelData.properties["application.name"])
91+ ? modelData.properties["application.name"] : flick.label(modelData)
92+ color: Theme.fg
93+ font.family: Theme.font
94+ font.pixelSize: Theme.fontSize - 1
95+ }
96+ SliderRow {
97+ Layout.fillWidth: true
98+ icon: Theme.volIcon(modelData.audio.volume, modelData.audio.muted)
99+ value: modelData.audio.volume
100+ muted: modelData.audio.muted
101+ onMoved: v => modelData.audio.volume = v
102+ onIconClicked: modelData.audio.muted = !modelData.audio.muted
103+ }
104+ }
105+ }
106+ Item { Layout.preferredHeight: 8 }
107+ }
108+}
A .config/quickshell/wal-control/BarSlider.qml View
@@ -0,0 +1,58 @@
1+import QtQuick
2+
3+Item {
4+ id: s
5+ property real value: 0
6+ property real from: 0
7+ property real to: 1
8+ property real step: 0.02
9+ property bool muted: false
10+ signal moved(real v)
11+
12+ readonly property real frac: to > from ? Math.max(0, Math.min(1, (value - from) / (to - from))) : 0
13+
14+ implicitHeight: 20
15+
16+ Rectangle {
17+ id: track
18+ anchors.verticalCenter: parent.verticalCenter
19+ width: parent.width
20+ height: 6
21+ color: Theme.surfaceHi
22+
23+ Rectangle {
24+ width: parent.width * s.frac
25+ height: parent.height
26+ opacity: s.muted ? 0.35 : 1
27+ gradient: Gradient {
28+ orientation: Gradient.Horizontal
29+ GradientStop { position: 0; color: Theme.accentDim }
30+ GradientStop { position: 1; color: Theme.accent }
31+ }
32+ }
33+ }
34+
35+ Rectangle {
36+ x: Math.max(0, Math.min(s.width - width, s.width * s.frac - width / 2))
37+ anchors.verticalCenter: parent.verticalCenter
38+ width: 4
39+ height: 14
40+ color: Theme.fg
41+ opacity: s.muted ? 0.5 : 1
42+ }
43+
44+ MouseArea {
45+ anchors.fill: parent
46+ cursorShape: Qt.PointingHandCursor
47+ function setFromX(mx) {
48+ var f = Math.max(0, Math.min(1, mx / width))
49+ s.moved(s.from + f * (s.to - s.from))
50+ }
51+ onPressed: m => setFromX(m.x)
52+ onPositionChanged: m => { if (pressed) setFromX(m.x) }
53+ onWheel: w => {
54+ var d = (w.angleDelta.y > 0 ? 1 : -1) * s.step * (s.to - s.from)
55+ s.moved(Math.max(s.from, Math.min(s.to, s.value + d)))
56+ }
57+ }
58+}
A .config/quickshell/wal-control/Bright.qml View
@@ -0,0 +1,41 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+
6+// Backlight via brightnessctl. Hides itself if brightnessctl isn't installed.
7+Singleton {
8+ id: root
9+ property int percent: -1
10+ readonly property bool available: percent >= 0
11+ property int pending: -1
12+
13+ function refresh() { getProc.running = true }
14+ function setPercent(v) {
15+ percent = Math.round(v)
16+ pending = percent
17+ debounce.restart()
18+ }
19+
20+ Timer {
21+ id: debounce
22+ interval: 70
23+ onTriggered: {
24+ if (setProc.running) { debounce.restart(); return }
25+ setProc.command = ["brightnessctl", "set", Math.max(1, root.pending) + "%"]
26+ setProc.running = true
27+ }
28+ }
29+ Process { id: setProc }
30+ Process {
31+ id: getProc
32+ command: ["brightnessctl", "-m"]
33+ running: true
34+ stdout: StdioCollector {
35+ onStreamFinished: {
36+ var p = text.trim().split(",")
37+ if (p.length >= 4) root.percent = parseInt(p[3])
38+ }
39+ }
40+ }
41+}
A .config/quickshell/wal-control/BtPage.qml View
@@ -0,0 +1,143 @@
1+import QtQuick
2+import QtQuick.Layouts
3+import Quickshell.Bluetooth
4+
5+ColumnLayout {
6+ id: page
7+ property bool active: false
8+ spacing: 8
9+
10+ readonly property var adapter: Bluetooth.defaultAdapter
11+
12+ onActiveChanged: {
13+ if (adapter) adapter.discovering = active && adapter.enabled
14+ }
15+
16+ function paired(d) { return d.bonded === true || d.paired === true }
17+
18+ function looksLikeAddress(d) {
19+ return String(d.name).replace(/-/g, ":").toUpperCase() === String(d.address).toUpperCase()
20+ }
21+
22+ function devIcon(d) {
23+ var i = String(d.icon || "")
24+ if (i.indexOf("head") >= 0) return Theme.iHeadphones
25+ if (i.indexOf("mouse") >= 0) return Theme.iMouse
26+ if (i.indexOf("keyboard") >= 0) return Theme.iKeyboard
27+ if (i.indexOf("phone") >= 0) return Theme.iPhone
28+ if (i.indexOf("gaming") >= 0 || i.indexOf("joystick") >= 0) return Theme.iGamepad
29+ if (i.indexOf("speaker") >= 0) return Theme.iSpeaker
30+ return d.connected ? Theme.iBtConn : Theme.iBt
31+ }
32+
33+ function list() {
34+ var src = Bluetooth.devices.values, out = []
35+ var scanning = adapter ? adapter.discovering : false
36+ for (var i = 0; i < src.length; i++) {
37+ var d = src[i]
38+ if (d.connected || paired(d)) out.push(d)
39+ else if (scanning && !looksLikeAddress(d)) out.push(d)
40+ }
41+ out.sort(function (a, b) {
42+ if (a.connected !== b.connected) return a.connected ? -1 : 1
43+ var pa = paired(a), pb = paired(b)
44+ if (pa !== pb) return pa ? -1 : 1
45+ return String(a.name).localeCompare(String(b.name))
46+ })
47+ return out
48+ }
49+ readonly property var devices: list()
50+
51+ RowLayout {
52+ Layout.fillWidth: true
53+ spacing: 6
54+ Text {
55+ Layout.fillWidth: true
56+ elide: Text.ElideRight
57+ text: !page.adapter ? "no bluetooth adapter"
58+ : (!page.adapter.enabled ? "bluetooth is off"
59+ : (page.adapter.discovering ? "scanning…" : (page.adapter.name || "adapter ready")))
60+ color: page.adapter && page.adapter.enabled ? Theme.accent : Theme.dim
61+ font.family: Theme.font
62+ font.pixelSize: Theme.fontSize
63+ }
64+ IconBtn {
65+ visible: page.adapter !== null
66+ icon: Theme.iSearch
67+ active: page.adapter ? page.adapter.discovering : false
68+ onClicked: if (page.adapter && page.adapter.enabled) page.adapter.discovering = !page.adapter.discovering
69+ }
70+ IconBtn {
71+ visible: page.adapter !== null
72+ icon: page.adapter && page.adapter.enabled ? Theme.iBt : Theme.iBtOff
73+ active: page.adapter ? page.adapter.enabled : false
74+ onClicked: {
75+ if (!page.adapter) return
76+ page.adapter.enabled = !page.adapter.enabled
77+ if (page.adapter.enabled && page.active) page.adapter.discovering = true
78+ }
79+ }
80+ }
81+
82+ SectionLabel { text: "devices" }
83+
84+ Flickable {
85+ id: flick
86+ Layout.fillWidth: true
87+ Layout.fillHeight: true
88+ clip: true
89+ contentWidth: width
90+ contentHeight: col.implicitHeight
91+ boundsBehavior: Flickable.StopAtBounds
92+
93+ ColumnLayout {
94+ id: col
95+ width: flick.width
96+ spacing: 2
97+
98+ Repeater {
99+ model: page.adapter && page.adapter.enabled ? page.devices : []
100+ delegate: ListRow {
101+ id: row
102+ required property var modelData
103+ readonly property var dev: modelData
104+ property bool pendingConnect: false
105+
106+ function afterPair() {
107+ if (pendingConnect && page.paired(dev)) { pendingConnect = false; dev.connect() }
108+ }
109+
110+ icon: page.devIcon(dev)
111+ title: dev.name || dev.address
112+ current: dev.connected
113+ subtitle: {
114+ if (dev.state === BluetoothDeviceState.Connecting) return "connecting…"
115+ if (dev.pairing) return "pairing…"
116+ if (dev.connected)
117+ return "connected" + (dev.batteryAvailable ? " · " + Math.round(dev.battery * 100) + "%" : "")
118+ return page.paired(dev) ? "paired" : "available"
119+ }
120+ onClicked: {
121+ if (dev.connected) dev.disconnect()
122+ else if (page.paired(dev)) dev.connect()
123+ else { pendingConnect = true; dev.trusted = true; dev.pair() }
124+ }
125+
126+ // property name differs between Quickshell versions
127+ Connections {
128+ target: row.dev
129+ ignoreUnknownSignals: true
130+ function onBondedChanged() { row.afterPair() }
131+ function onPairedChanged() { row.afterPair() }
132+ }
133+ IconBtn {
134+ visible: page.paired(row.dev)
135+ icon: Theme.iTrash
136+ height: 26
137+ onClicked: row.dev.forget()
138+ }
139+ }
140+ }
141+ }
142+ }
143+}
A .config/quickshell/wal-control/HomePage.qml View
@@ -0,0 +1,100 @@
1+import QtQuick
2+import QtQuick.Layouts
3+import Quickshell
4+import Quickshell.Services.Pipewire
5+
6+ColumnLayout {
7+ id: page
8+ spacing: 12
9+ signal closeRequested()
10+
11+ property string armed: ""
12+ Timer { id: disarm; interval: 3000; onTriggered: page.armed = "" }
13+
14+ function act(name, cmd, confirm) {
15+ if (confirm && armed !== name) { armed = name; disarm.restart(); return }
16+ armed = ""
17+ Quickshell.execDetached(cmd)
18+ closeRequested()
19+ }
20+
21+ Timer {
22+ interval: 3000
23+ running: page.visible
24+ repeat: true
25+ onTriggered: Bright.refresh()
26+ }
27+
28+ MediaCard { Layout.fillWidth: true }
29+
30+ SectionLabel { text: "levels" }
31+
32+ SliderRow {
33+ Layout.fillWidth: true
34+ readonly property var audio: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.audio : null
35+ icon: Theme.volIcon(audio ? audio.volume : 0, audio ? audio.muted : true)
36+ value: audio ? audio.volume : 0
37+ muted: audio ? audio.muted : false
38+ onMoved: v => { if (audio) audio.volume = v }
39+ onIconClicked: if (audio) audio.muted = !audio.muted
40+ }
41+
42+ SliderRow {
43+ Layout.fillWidth: true
44+ readonly property var audio: Pipewire.defaultAudioSource ? Pipewire.defaultAudioSource.audio : null
45+ icon: audio && !audio.muted ? Theme.iMic : Theme.iMicOff
46+ value: audio ? audio.volume : 0
47+ muted: audio ? audio.muted : false
48+ onMoved: v => { if (audio) audio.volume = v }
49+ onIconClicked: if (audio) audio.muted = !audio.muted
50+ }
51+
52+ SliderRow {
53+ Layout.fillWidth: true
54+ visible: Bright.available
55+ icon: Theme.iBright
56+ value: Bright.percent / 100
57+ valueText: Bright.percent + "%"
58+ onMoved: v => Bright.setPercent(Math.max(0.01, v) * 100)
59+ }
60+
61+ Item { Layout.fillHeight: true }
62+
63+ SectionLabel { text: "session" }
64+
65+ RowLayout {
66+ Layout.fillWidth: true
67+ spacing: 6
68+ IconBtn {
69+ Layout.fillWidth: true
70+ icon: Theme.iLock
71+ onClicked: page.act("lock", ["loginctl", "lock-session"], false)
72+ }
73+ IconBtn {
74+ Layout.fillWidth: true
75+ icon: Theme.iSleep
76+ onClicked: page.act("sleep", ["systemctl", "suspend"], false)
77+ }
78+ IconBtn {
79+ Layout.fillWidth: true
80+ icon: Theme.iLogout
81+ label: page.armed === "logout" ? "sure?" : ""
82+ active: page.armed === "logout"
83+ onClicked: page.act("logout", ["hyprctl", "dispatch", "exit"], true)
84+ }
85+ IconBtn {
86+ Layout.fillWidth: true
87+ icon: Theme.iReboot
88+ label: page.armed === "reboot" ? "sure?" : ""
89+ active: page.armed === "reboot"
90+ onClicked: page.act("reboot", ["systemctl", "reboot"], true)
91+ }
92+ IconBtn {
93+ Layout.fillWidth: true
94+ icon: Theme.iPower
95+ label: page.armed === "off" ? "sure?" : ""
96+ active: page.armed === "off"
97+ onClicked: page.act("off", ["systemctl", "poweroff"], true)
98+ }
99+ }
100+}
A .config/quickshell/wal-control/IconBtn.qml View
@@ -0,0 +1,46 @@
1+import QtQuick
2+
3+Rectangle {
4+ id: b
5+ property string icon: ""
6+ property string label: ""
7+ property bool active: false
8+ property int iconSize: 15
9+ signal clicked()
10+
11+ implicitWidth: Math.max(30, content.implicitWidth + 16)
12+ implicitHeight: 30
13+ color: active ? Theme.accent : (m.containsMouse ? Theme.surfaceHi : "transparent")
14+ border.width: 1
15+ border.color: (active || m.containsMouse) ? Theme.accent : Theme.line
16+
17+ Row {
18+ id: content
19+ anchors.centerIn: parent
20+ spacing: 6
21+ Text {
22+ visible: b.icon !== ""
23+ text: b.icon
24+ color: b.active ? Theme.bg : Theme.fg
25+ font.family: Theme.font
26+ font.pixelSize: b.iconSize
27+ anchors.verticalCenter: parent.verticalCenter
28+ }
29+ Text {
30+ visible: b.label !== ""
31+ text: b.label
32+ color: b.active ? Theme.bg : Theme.fg
33+ font.family: Theme.font
34+ font.pixelSize: Theme.fontSize - 1
35+ anchors.verticalCenter: parent.verticalCenter
36+ }
37+ }
38+
39+ MouseArea {
40+ id: m
41+ anchors.fill: parent
42+ hoverEnabled: true
43+ cursorShape: Qt.PointingHandCursor
44+ onClicked: b.clicked()
45+ }
46+}
A .config/quickshell/wal-control/ListRow.qml View
@@ -0,0 +1,72 @@
1+import QtQuick
2+import QtQuick.Layouts
3+
4+Rectangle {
5+ id: r
6+ property string icon: ""
7+ property string title: ""
8+ property string subtitle: ""
9+ property string trailing: ""
10+ property bool current: false
11+ signal clicked()
12+ // anything declared inside a ListRow{} lands in the right-hand slot
13+ default property alias extra: extraRow.data
14+
15+ Layout.fillWidth: true
16+ implicitHeight: 42
17+ color: m.containsMouse ? Theme.surfaceHi : (current ? Theme.surface : "transparent")
18+ border.width: current ? 1 : 0
19+ border.color: Theme.accent
20+
21+ MouseArea {
22+ id: m
23+ anchors.fill: parent
24+ hoverEnabled: true
25+ cursorShape: Qt.PointingHandCursor
26+ onClicked: r.clicked()
27+ }
28+
29+ RowLayout {
30+ anchors.fill: parent
31+ anchors.leftMargin: 10
32+ anchors.rightMargin: 6
33+ spacing: 10
34+
35+ Text {
36+ text: r.icon
37+ color: r.current ? Theme.accent : Theme.fg
38+ font.family: Theme.font
39+ font.pixelSize: 17
40+ }
41+ ColumnLayout {
42+ Layout.fillWidth: true
43+ spacing: 0
44+ Text {
45+ text: r.title
46+ Layout.fillWidth: true
47+ elide: Text.ElideRight
48+ color: Theme.fg
49+ font.family: Theme.font
50+ font.pixelSize: Theme.fontSize
51+ font.bold: r.current
52+ }
53+ Text {
54+ visible: r.subtitle !== ""
55+ text: r.subtitle
56+ Layout.fillWidth: true
57+ elide: Text.ElideRight
58+ color: r.current ? Theme.accent : Theme.dim
59+ font.family: Theme.font
60+ font.pixelSize: Theme.fontSize - 3
61+ }
62+ }
63+ Text {
64+ visible: r.trailing !== ""
65+ text: r.trailing
66+ color: Theme.dim
67+ font.family: Theme.font
68+ font.pixelSize: Theme.fontSize - 2
69+ }
70+ Row { id: extraRow; spacing: 4 }
71+ }
72+}
A .config/quickshell/wal-control/MediaCard.qml View
@@ -0,0 +1,152 @@
1+import QtQuick
2+import QtQuick.Layouts
3+import Quickshell.Services.Mpris
4+
5+Rectangle {
6+ id: card
7+
8+ function pick() {
9+ var l = Mpris.players.values, first = null
10+ for (var i = 0; i < l.length; i++) {
11+ if (l[i].isPlaying) return l[i]
12+ if (first === null) first = l[i]
13+ }
14+ return first
15+ }
16+ function fmt(s) {
17+ s = Math.max(0, Math.floor(s))
18+ var m = Math.floor(s / 60), r = s % 60
19+ return m + ":" + (r < 10 ? "0" : "") + r
20+ }
21+
22+ readonly property var player: pick()
23+ readonly property bool has: player !== null
24+ readonly property bool seekable: has && player.lengthSupported && player.length > 0
25+
26+ implicitHeight: has ? (seekable ? 156 : 122) : 56
27+ color: Theme.surface
28+ border.width: 1
29+ border.color: Theme.line
30+
31+ // MPRIS doesn't push position updates, so poke it while playing
32+ Timer {
33+ running: card.has && card.player.isPlaying && card.visible
34+ interval: 500
35+ repeat: true
36+ onTriggered: card.player.positionChanged()
37+ }
38+
39+ Text {
40+ visible: !card.has
41+ anchors.centerIn: parent
42+ text: Theme.iMusic + " nothing playing"
43+ color: Theme.dim
44+ font.family: Theme.font
45+ font.pixelSize: Theme.fontSize
46+ }
47+
48+ ColumnLayout {
49+ visible: card.has
50+ anchors.fill: parent
51+ anchors.margins: 10
52+ spacing: 6
53+
54+ RowLayout {
55+ spacing: 10
56+ Rectangle {
57+ Layout.preferredWidth: 60
58+ Layout.preferredHeight: 60
59+ color: Theme.surfaceHi
60+ Text {
61+ anchors.centerIn: parent
62+ text: Theme.iMusic
63+ color: Theme.dim
64+ font.family: Theme.font
65+ font.pixelSize: 24
66+ }
67+ Image {
68+ id: art
69+ anchors.fill: parent
70+ source: card.has ? card.player.trackArtUrl : ""
71+ fillMode: Image.PreserveAspectCrop
72+ asynchronous: true
73+ visible: status === Image.Ready
74+ }
75+ }
76+ ColumnLayout {
77+ Layout.fillWidth: true
78+ spacing: 2
79+ Text {
80+ Layout.fillWidth: true
81+ elide: Text.ElideRight
82+ text: card.has ? (card.player.trackTitle || "unknown title") : ""
83+ color: Theme.fg
84+ font.family: Theme.font
85+ font.pixelSize: Theme.fontSize + 1
86+ font.bold: true
87+ }
88+ Text {
89+ Layout.fillWidth: true
90+ elide: Text.ElideRight
91+ text: card.has ? (card.player.trackArtist || "unknown artist") : ""
92+ color: Theme.accent
93+ font.family: Theme.font
94+ font.pixelSize: Theme.fontSize
95+ }
96+ Text {
97+ Layout.fillWidth: true
98+ elide: Text.ElideRight
99+ text: card.has ? ((card.player.trackAlbum ? card.player.trackAlbum + " · " : "") + card.player.identity) : ""
100+ color: Theme.dim
101+ font.family: Theme.font
102+ font.pixelSize: Theme.fontSize - 2
103+ }
104+ }
105+ }
106+
107+ RowLayout {
108+ visible: card.seekable
109+ spacing: 8
110+ Text {
111+ text: card.seekable ? card.fmt(card.player.position) : ""
112+ color: Theme.dim
113+ font.family: Theme.font
114+ font.pixelSize: Theme.fontSize - 2
115+ }
116+ BarSlider {
117+ Layout.fillWidth: true
118+ from: 0
119+ to: card.seekable ? card.player.length : 1
120+ value: card.seekable ? card.player.position : 0
121+ onMoved: v => { if (card.player.canSeek) card.player.position = v }
122+ }
123+ Text {
124+ text: card.seekable ? card.fmt(card.player.length) : ""
125+ color: Theme.dim
126+ font.family: Theme.font
127+ font.pixelSize: Theme.fontSize - 2
128+ }
129+ }
130+
131+ RowLayout {
132+ Layout.alignment: Qt.AlignHCenter
133+ spacing: 8
134+ IconBtn {
135+ icon: Theme.iPrev
136+ opacity: card.has && card.player.canGoPrevious ? 1 : 0.4
137+ onClicked: if (card.player.canGoPrevious) card.player.previous()
138+ }
139+ IconBtn {
140+ icon: card.has && card.player.isPlaying ? Theme.iPause : Theme.iPlay
141+ active: true
142+ implicitWidth: 44
143+ onClicked: if (card.player.canTogglePlaying) card.player.togglePlaying()
144+ }
145+ IconBtn {
146+ icon: Theme.iNext
147+ opacity: card.has && card.player.canGoNext ? 1 : 0.4
148+ onClicked: if (card.player.canGoNext) card.player.next()
149+ }
150+ }
151+ }
152+}
A .config/quickshell/wal-control/Net.qml View
@@ -0,0 +1,198 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+
6+// Wi-Fi backend: thin wrapper over nmcli (NetworkManager).
7+Singleton {
8+ id: root
9+
10+ property bool active: false // NetPage sets this while visible
11+ property bool wifiEnabled: true
12+ property string wifiDevice: ""
13+ property var raw: [] // [{inUse, ssid, signal, security}]
14+ property var savedNames: []
15+ property string askFor: "" // ssid currently asking for a password
16+ property string message: ""
17+ property bool busy: false
18+ property bool scanning: false
19+
20+ property string lastSsid: ""
21+ property bool lastWasNew: false
22+
23+ readonly property var networks: build(raw, savedNames)
24+ readonly property string connected: {
25+ for (var i = 0; i < raw.length; i++) if (raw[i].inUse) return raw[i].ssid
26+ return ""
27+ }
28+
29+ onActiveChanged: if (active) { askFor = ""; message = ""; rescan() }
30+
31+ // nmcli -t output: ':' separates fields, '\:' and '\\' are escapes
32+ function parseTerse(line) {
33+ var out = [], cur = ""
34+ for (var i = 0; i < line.length; i++) {
35+ var ch = line[i]
36+ if (ch === "\\" && i + 1 < line.length) { cur += line[i + 1]; i++ }
37+ else if (ch === ":") { out.push(cur); cur = "" }
38+ else cur += ch
39+ }
40+ out.push(cur)
41+ return out
42+ }
43+
44+ function build(list, saved) {
45+ var out = []
46+ for (var i = 0; i < list.length; i++) {
47+ var n = list[i]
48+ out.push({
49+ ssid: n.ssid, signal: n.signal, security: n.security, inUse: n.inUse,
50+ saved: saved.indexOf(n.ssid) >= 0
51+ })
52+ }
53+ out.sort(function (a, b) {
54+ if (a.inUse !== b.inUse) return a.inUse ? -1 : 1
55+ return b.signal - a.signal
56+ })
57+ return out
58+ }
59+
60+ function refresh() {
61+ radioProc.running = true
62+ devProc.running = true
63+ savedProc.running = true
64+ listProc.running = true
65+ }
66+
67+ function rescan() {
68+ scanning = true
69+ rescanProc.running = true
70+ refresh()
71+ }
72+
73+ // ---- actions ------------------------------------------------------
74+ function run(args) {
75+ if (busy) return
76+ busy = true
77+ runProc.command = ["sh", "-c",
78+ 'out=$(nmcli "$@" 2>&1); rc=$?; printf "%s\\n%d" "$out" "$rc"', "sh"].concat(args)
79+ runProc.running = true
80+ }
81+
82+ function finish(text) {
83+ busy = false
84+ var lines = text.split("\n")
85+ var rc = parseInt(lines.pop())
86+ var out = lines.join(" ").trim()
87+ if (rc !== 0) {
88+ message = out
89+ // a failed first-time connect leaves a broken profile behind; drop it
90+ if (lastWasNew && lastSsid !== "")
91+ Quickshell.execDetached(["nmcli", "connection", "delete", "id", lastSsid])
92+ } else {
93+ message = ""
94+ askFor = ""
95+ }
96+ lastWasNew = false
97+ refresh()
98+ }
99+
100+ function connectTo(entry) {
101+ if (entry.inUse) return
102+ if (entry.saved || entry.security === "" || entry.security === "--") connect(entry.ssid, "")
103+ else askFor = (askFor === entry.ssid) ? "" : entry.ssid
104+ }
105+
106+ function connect(ssid, password) {
107+ message = ""
108+ lastSsid = ssid
109+ var isSaved = savedNames.indexOf(ssid) >= 0
110+ if (isSaved && !password) {
111+ lastWasNew = false
112+ run(["connection", "up", "id", ssid])
113+ } else {
114+ lastWasNew = !isSaved
115+ var cmd = ["--wait", "20", "device", "wifi", "connect", ssid]
116+ if (password) cmd = cmd.concat(["password", password])
117+ run(cmd)
118+ }
119+ }
120+
121+ function disconnect() { if (wifiDevice !== "") run(["device", "disconnect", wifiDevice]) }
122+ function forget(ssid) { run(["connection", "delete", "id", ssid]) }
123+ function setWifi(on) { run(["radio", "wifi", on ? "on" : "off"]) }
124+
125+ // ---- processes ----------------------------------------------------
126+ Timer {
127+ interval: 10000
128+ running: root.active
129+ repeat: true
130+ onTriggered: root.refresh()
131+ }
132+
133+ Process {
134+ id: runProc
135+ stdout: StdioCollector { onStreamFinished: root.finish(text) }
136+ }
137+
138+ Process {
139+ id: rescanProc
140+ command: ["nmcli", "device", "wifi", "rescan"]
141+ onExited: { root.scanning = false; listProc.running = true }
142+ }
143+
144+ Process {
145+ id: radioProc
146+ command: ["nmcli", "radio", "wifi"]
147+ stdout: StdioCollector { onStreamFinished: root.wifiEnabled = text.trim() === "enabled" }
148+ }
149+
150+ Process {
151+ id: devProc
152+ command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"]
153+ stdout: StdioCollector {
154+ onStreamFinished: {
155+ var lines = text.split("\n")
156+ for (var i = 0; i < lines.length; i++) {
157+ var f = root.parseTerse(lines[i])
158+ if (f.length >= 2 && f[1] === "wifi") { root.wifiDevice = f[0]; return }
159+ }
160+ }
161+ }
162+ }
163+
164+ Process {
165+ id: savedProc
166+ command: ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"]
167+ stdout: StdioCollector {
168+ onStreamFinished: {
169+ var out = [], lines = text.split("\n")
170+ for (var i = 0; i < lines.length; i++) {
171+ var f = root.parseTerse(lines[i])
172+ if (f.length >= 2 && f[1].indexOf("wireless") >= 0) out.push(f[0])
173+ }
174+ root.savedNames = out
175+ }
176+ }
177+ }
178+
179+ Process {
180+ id: listProc
181+ command: ["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list"]
182+ stdout: StdioCollector {
183+ onStreamFinished: {
184+ var best = {}, lines = text.split("\n")
185+ for (var i = 0; i < lines.length; i++) {
186+ var f = root.parseTerse(lines[i])
187+ if (f.length < 4 || f[1] === "") continue
188+ var e = { inUse: f[0] === "*", ssid: f[1], signal: parseInt(f[2]) || 0, security: f[3] }
189+ var o = best[e.ssid]
190+ if (!o || e.inUse || (!o.inUse && e.signal > o.signal)) best[e.ssid] = e
191+ }
192+ var out = []
193+ for (var k in best) out.push(best[k])
194+ root.raw = out
195+ }
196+ }
197+ }
198+}
A .config/quickshell/wal-control/NetPage.qml View
@@ -0,0 +1,136 @@
1+import QtQuick
2+import QtQuick.Layouts
3+
4+ColumnLayout {
5+ id: page
6+ property bool active: false
7+ onActiveChanged: Net.active = active
8+ spacing: 8
9+
10+ RowLayout {
11+ Layout.fillWidth: true
12+ spacing: 6
13+ Text {
14+ Layout.fillWidth: true
15+ elide: Text.ElideRight
16+ text: !Net.wifiEnabled ? "wi-fi is off"
17+ : (Net.connected !== "" ? "connected · " + Net.connected : "not connected")
18+ color: Net.connected !== "" && Net.wifiEnabled ? Theme.accent : Theme.dim
19+ font.family: Theme.font
20+ font.pixelSize: Theme.fontSize
21+ }
22+ IconBtn {
23+ icon: Theme.iRefresh
24+ active: Net.scanning
25+ onClicked: Net.rescan()
26+ }
27+ IconBtn {
28+ icon: Net.wifiEnabled ? Theme.iWifi : Theme.iWifiOff
29+ active: Net.wifiEnabled
30+ onClicked: Net.setWifi(!Net.wifiEnabled)
31+ }
32+ }
33+
34+ Text {
35+ visible: Net.message !== ""
36+ Layout.fillWidth: true
37+ text: Net.message
38+ wrapMode: Text.WordWrap
39+ color: Theme.err
40+ font.family: Theme.font
41+ font.pixelSize: Theme.fontSize - 2
42+ }
43+
44+ SectionLabel { text: Net.busy ? "working…" : "networks" }
45+
46+ Flickable {
47+ id: flick
48+ Layout.fillWidth: true
49+ Layout.fillHeight: true
50+ clip: true
51+ contentWidth: width
52+ contentHeight: list.implicitHeight
53+ boundsBehavior: Flickable.StopAtBounds
54+
55+ ColumnLayout {
56+ id: list
57+ width: flick.width
58+ spacing: 2
59+
60+ Repeater {
61+ model: Net.wifiEnabled ? Net.networks : []
62+ delegate: ColumnLayout {
63+ id: item
64+ required property var modelData
65+ Layout.fillWidth: true
66+ spacing: 0
67+
68+ ListRow {
69+ icon: Theme.wifiIcon(item.modelData.signal)
70+ title: item.modelData.ssid
71+ subtitle: item.modelData.inUse ? "connected"
72+ : (item.modelData.saved ? "saved"
73+ : (item.modelData.security === "" ? "open" : item.modelData.security))
74+ trailing: item.modelData.signal + "%"
75+ current: item.modelData.inUse
76+ onClicked: Net.connectTo(item.modelData)
77+
78+ IconBtn {
79+ visible: item.modelData.inUse
80+ icon: Theme.iClose
81+ height: 26
82+ onClicked: Net.disconnect()
83+ }
84+ IconBtn {
85+ visible: item.modelData.saved
86+ icon: Theme.iTrash
87+ height: 26
88+ onClicked: Net.forget(item.modelData.ssid)
89+ }
90+ }
91+
92+ Rectangle {
93+ id: pwBox
94+ visible: Net.askFor === item.modelData.ssid
95+ Layout.fillWidth: true
96+ Layout.preferredHeight: visible ? 34 : 0
97+ color: Theme.surface
98+ border.width: 1
99+ border.color: Theme.accent
100+ onVisibleChanged: if (visible) { pw.text = ""; pw.forceActiveFocus() }
101+
102+ RowLayout {
103+ anchors.fill: parent
104+ anchors.leftMargin: 10
105+ anchors.rightMargin: 4
106+ TextInput {
107+ id: pw
108+ Layout.fillWidth: true
109+ Layout.alignment: Qt.AlignVCenter
110+ echoMode: TextInput.Password
111+ color: Theme.fg
112+ selectionColor: Theme.accent
113+ selectedTextColor: Theme.bg
114+ font.family: Theme.font
115+ font.pixelSize: Theme.fontSize
116+ clip: true
117+ onAccepted: Net.connect(item.modelData.ssid, text)
118+ Text {
119+ visible: !pw.text
120+ text: "password…"
121+ color: Theme.dim
122+ font: pw.font
123+ }
124+ }
125+ IconBtn {
126+ icon: Theme.iCheck
127+ height: 26
128+ onClicked: Net.connect(item.modelData.ssid, pw.text)
129+ }
130+ }
131+ }
132+ }
133+ }
134+ }
135+ }
136+}
A .config/quickshell/wal-control/SectionLabel.qml View
@@ -0,0 +1,17 @@
1+import QtQuick
2+import QtQuick.Layouts
3+
4+RowLayout {
5+ id: r
6+ property string text: ""
7+ Layout.fillWidth: true
8+ spacing: 8
9+ Text {
10+ text: r.text.toUpperCase()
11+ color: Theme.accent
12+ font.family: Theme.font
13+ font.pixelSize: Theme.fontSize - 2
14+ font.letterSpacing: 1.5
15+ }
16+ Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: Theme.line }
17+}
A .config/quickshell/wal-control/SliderRow.qml View
@@ -0,0 +1,30 @@
1+import QtQuick
2+import QtQuick.Layouts
3+
4+RowLayout {
5+ id: r
6+ property string icon: ""
7+ property real value: 0
8+ property bool muted: false
9+ property string valueText: Math.round(value * 100) + "%"
10+ signal moved(real v)
11+ signal iconClicked()
12+
13+ spacing: 8
14+
15+ IconBtn { icon: r.icon; onClicked: r.iconClicked() }
16+ BarSlider {
17+ Layout.fillWidth: true
18+ value: r.value
19+ muted: r.muted
20+ onMoved: v => r.moved(v)
21+ }
22+ Text {
23+ text: r.valueText
24+ Layout.preferredWidth: 40
25+ horizontalAlignment: Text.AlignRight
26+ color: r.muted ? Theme.dim : Theme.fg
27+ font.family: Theme.font
28+ font.pixelSize: Theme.fontSize
29+ }
30+}
A .config/quickshell/wal-control/Theme.qml View
@@ -0,0 +1,131 @@
1+pragma Singleton
2+import QtQuick
3+import Quickshell
4+import Quickshell.Io
5+
6+// Reads ~/.cache/wal/colors.json (pywal16) and hot-reloads whenever `wal` runs.
7+Singleton {
8+ id: root
9+
10+ // ---- knobs --------------------------------------------------------
11+ property string font: "JetBrainsMono Nerd Font" // any Nerd Font mono
12+ property int fontSize: 12
13+ property real panelAlpha: 0.88
14+ // -1 = auto-pick the most vivid of color1..color6, or force 0..15
15+ property int accentIndex: -1
16+
17+ readonly property string walFile: Quickshell.env("HOME") + "/.cache/wal/colors.json"
18+
19+ FileView {
20+ path: root.walFile
21+ watchChanges: true
22+ onFileChanged: reload()
23+ adapter: JsonAdapter {
24+ id: wal
25+ property JsonObject special: JsonObject {
26+ property string background: "#0b1514"
27+ property string foreground: "#cfe3df"
28+ property string cursor: "#cfe3df"
29+ }
30+ property JsonObject colors: JsonObject {
31+ property string color0: "#0b1514"
32+ property string color1: "#c0616b"
33+ property string color2: "#3fa593"
34+ property string color3: "#7fb6a8"
35+ property string color4: "#3a8f8a"
36+ property string color5: "#5f9f9a"
37+ property string color6: "#4fbfae"
38+ property string color7: "#cfe3df"
39+ property string color8: "#4a5f5c"
40+ property string color9: "#d97a84"
41+ property string color10: "#52c2ae"
42+ property string color11: "#9ad0c3"
43+ property string color12: "#4fb0a9"
44+ property string color13: "#78b8b2"
45+ property string color14: "#6fd6c5"
46+ property string color15: "#e3f2ef"
47+ }
48+ }
49+ }
50+
51+ readonly property var pal: [
52+ wal.colors.color0, wal.colors.color1, wal.colors.color2, wal.colors.color3,
53+ wal.colors.color4, wal.colors.color5, wal.colors.color6, wal.colors.color7,
54+ wal.colors.color8, wal.colors.color9, wal.colors.color10, wal.colors.color11,
55+ wal.colors.color12, wal.colors.color13, wal.colors.color14, wal.colors.color15
56+ ]
57+
58+ function mix(a, b, t) {
59+ return Qt.rgba(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, 1)
60+ }
61+
62+ function autoAccent() {
63+ var best = 4, score = -1
64+ for (var i = 1; i <= 6; i++) {
65+ var c = Qt.color(pal[i])
66+ var s = c.hsvSaturation * c.hsvValue
67+ if (s > score) { score = s; best = i }
68+ }
69+ return Qt.color(pal[best])
70+ }
71+
72+ // ---- palette ------------------------------------------------------
73+ readonly property color bg: wal.special.background
74+ readonly property color fg: wal.special.foreground
75+ readonly property color accent: accentIndex >= 0 ? Qt.color(pal[accentIndex]) : autoAccent()
76+ readonly property color accentDim: mix(bg, accent, 0.45)
77+ readonly property color surface: mix(bg, fg, 0.06)
78+ readonly property color surfaceHi: mix(bg, fg, 0.14)
79+ readonly property color line: mix(bg, accent, 0.35)
80+ readonly property color dim: mix(bg, fg, 0.55)
81+ readonly property color err: Qt.color(pal[1])
82+ readonly property color bgA: Qt.rgba(bg.r, bg.g, bg.b, panelAlpha)
83+
84+ // ---- Nerd Font (Material Design) glyphs ---------------------------
85+ function cp(c) {
86+ c -= 0x10000
87+ return String.fromCharCode(0xD800 + (c >> 10), 0xDC00 + (c & 0x3FF))
88+ }
89+ readonly property string iHome: cp(0xF02DC)
90+ readonly property string iVolHigh: cp(0xF057E)
91+ readonly property string iVolMed: cp(0xF0580)
92+ readonly property string iVolLow: cp(0xF057F)
93+ readonly property string iVolOff: cp(0xF0581)
94+ readonly property string iMic: cp(0xF036C)
95+ readonly property string iMicOff: cp(0xF036D)
96+ readonly property string iWifi: cp(0xF05A9)
97+ readonly property string iWifiOff: cp(0xF05AA)
98+ readonly property string iBt: cp(0xF00AF)
99+ readonly property string iBtOff: cp(0xF00B2)
100+ readonly property string iBtConn: cp(0xF00B1)
101+ readonly property string iPower: cp(0xF0425)
102+ readonly property string iClose: cp(0xF0156)
103+ readonly property string iPrev: cp(0xF04AE)
104+ readonly property string iNext: cp(0xF04AD)
105+ readonly property string iPlay: cp(0xF040A)
106+ readonly property string iPause: cp(0xF03E4)
107+ readonly property string iBright: cp(0xF00DF)
108+ readonly property string iLock: cp(0xF033E)
109+ readonly property string iLogout: cp(0xF0343)
110+ readonly property string iSleep: cp(0xF04B2)
111+ readonly property string iReboot: cp(0xF0709)
112+ readonly property string iRefresh: cp(0xF0450)
113+ readonly property string iHeadphones: cp(0xF02CB)
114+ readonly property string iSpeaker: cp(0xF04C3)
115+ readonly property string iMusic: cp(0xF075A)
116+ readonly property string iTrash: cp(0xF01B4)
117+ readonly property string iCheck: cp(0xF012C)
118+ readonly property string iMouse: cp(0xF037D)
119+ readonly property string iKeyboard: cp(0xF030C)
120+ readonly property string iPhone: cp(0xF011C)
121+ readonly property string iGamepad: cp(0xF0297)
122+ readonly property string iSearch: cp(0xF0349)
123+
124+ function volIcon(v, muted) {
125+ if (muted || v <= 0) return iVolOff
126+ return v < 0.34 ? iVolLow : (v < 0.67 ? iVolMed : iVolHigh)
127+ }
128+ function wifiIcon(sig) {
129+ return cp(sig >= 75 ? 0xF0928 : sig >= 50 ? 0xF0925 : sig >= 25 ? 0xF0922 : 0xF091F)
130+ }
131+}
A .config/quickshell/wal-control/shell.qml View
@@ -0,0 +1,118 @@
1+import QtQuick
2+import QtQuick.Layouts
3+import Quickshell
4+import Quickshell.Io
5+import Quickshell.Wayland
6+import Quickshell.Hyprland
7+import Quickshell.Services.Pipewire
8+
9+ShellRoot {
10+ id: root
11+ property bool opened: false
12+ property int tab: 0
13+ readonly property var tabNames: ["home", "audio", "net", "bt"]
14+
15+ function openTab(name) {
16+ var i = tabNames.indexOf(name)
17+ if (i >= 0) tab = i
18+ opened = true
19+ }
20+
21+ // keep every pipewire node bound so volume / mute / properties are readable
22+ PwObjectTracker { objects: Pipewire.nodes.values }
23+
24+ PanelWindow {
25+ id: panel
26+ visible: root.opened
27+ anchors { top: true; right: true }
28+ margins { top: 2 ; right: 2 }
29+ implicitWidth: 430
30+ implicitHeight: 590
31+ exclusiveZone: 0
32+ color: "transparent"
33+ WlrLayershell.layer: WlrLayer.Overlay
34+ WlrLayershell.namespace: "wal-control"
35+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
36+
37+ Rectangle {
38+ anchors.fill: parent
39+ color: Theme.bgA
40+ border.width: 2
41+ border.color: Theme.accent
42+ focus: true
43+ Keys.onEscapePressed: root.opened = false
44+
45+ ColumnLayout {
46+ anchors.fill: parent
47+ anchors.margins: 12
48+ spacing: 10
49+
50+ RowLayout {
51+ Layout.fillWidth: true
52+ spacing: 2
53+ Repeater {
54+ model: [
55+ { icon: Theme.iHome, name: "home" },
56+ { icon: Theme.iVolHigh, name: "audio" },
57+ { icon: Theme.iWifi, name: "net" },
58+ { icon: Theme.iBt, name: "bt" }
59+ ]
60+ delegate: Rectangle {
61+ id: tabBtn
62+ required property var modelData
63+ required property int index
64+ implicitWidth: lbl.implicitWidth + 22
65+ implicitHeight: 28
66+ color: root.tab === index ? Theme.accent : (tm.containsMouse ? Theme.surfaceHi : "transparent")
67+ Text {
68+ id: lbl
69+ anchors.centerIn: parent
70+ text: tabBtn.modelData.icon + " " + tabBtn.modelData.name
71+ color: root.tab === tabBtn.index ? Theme.bg : Theme.fg
72+ font.family: Theme.font
73+ font.pixelSize: Theme.fontSize
74+ }
75+ MouseArea {
76+ id: tm
77+ anchors.fill: parent
78+ hoverEnabled: true
79+ cursorShape: Qt.PointingHandCursor
80+ onClicked: root.tab = tabBtn.index
81+ }
82+ }
83+ }
84+ Item { Layout.fillWidth: true }
85+ IconBtn { icon: Theme.iClose; height: 28; onClicked: root.opened = false }
86+ }
87+
88+ Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: Theme.line }
89+
90+ StackLayout {
91+ Layout.fillWidth: true
92+ Layout.fillHeight: true
93+ currentIndex: root.tab
94+
95+ HomePage { onCloseRequested: root.opened = false }
96+ AudioPage {}
97+ NetPage { active: root.opened && root.tab === 2 }
98+ BtPage { active: root.opened && root.tab === 3 }
99+ }
100+ }
101+ }
102+ }
103+
104+ // clicking anywhere outside the panel closes it
105+ HyprlandFocusGrab {
106+ windows: [panel]
107+ active: root.opened
108+ onCleared: root.opened = false
109+ }
110+
111+ IpcHandler {
112+ target: "control"
113+ function toggle(): void { root.opened = !root.opened }
114+ function open(): void { root.opened = true }
115+ function close(): void { root.opened = false }
116+ function show(name: string): void { root.openTab(name) }
117+ }
118+}
A .config/quickshell/wal-control/wal-control.service View
@@ -0,0 +1,12 @@
1+[Unit]
2+Description=pywal-themed Quickshell control center
3+After=graphical-session.target
4+PartOf=graphical-session.target
5+
6+[Service]
7+ExecStart=/usr/bin/quickshell -p %h/.config/quickshell/wal-control
8+Restart=on-failure
9+RestartSec=2
10+
11+[Install]
12+WantedBy=graphical-session.target