auroraa78a1e5
add wal-control quichshell
+1365 −0
16 files changed
- M .config/hypr/source/keybinds.lua +3 −0
- A .config/quickshell/wal-control/AudioPage.qml +108 −0
- A .config/quickshell/wal-control/BarSlider.qml +58 −0
- A .config/quickshell/wal-control/Bright.qml +41 −0
- A .config/quickshell/wal-control/BtPage.qml +143 −0
- A .config/quickshell/wal-control/HomePage.qml +100 −0
- A .config/quickshell/wal-control/IconBtn.qml +46 −0
- A .config/quickshell/wal-control/ListRow.qml +72 −0
- A .config/quickshell/wal-control/MediaCard.qml +152 −0
- A .config/quickshell/wal-control/Net.qml +198 −0
- A .config/quickshell/wal-control/NetPage.qml +136 −0
- A .config/quickshell/wal-control/SectionLabel.qml +17 −0
- A .config/quickshell/wal-control/SliderRow.qml +30 −0
- A .config/quickshell/wal-control/Theme.qml +131 −0
- A .config/quickshell/wal-control/shell.qml +118 −0
- A .config/quickshell/wal-control/wal-control.service +12 −0
| @@ -144,3 +144,6 @@ hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) | |||
| 144 | 144 | hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) | |
| 145 | 145 | hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) | |
| 146 | 146 | 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")) | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -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 | |