From 1c32b4066935bcf7cd38e7dc9213e3fa5983903f Mon Sep 17 00:00:00 2001 From: Johannes Knopp Date: Mon, 22 Jun 2026 15:46:38 +0200 Subject: [PATCH] add Bluetooth to quickshell bar --- roles/quickshell/files/bar/Bar.qml | 26 +++ .../quickshell/files/bar/BluetoothContent.qml | 187 ++++++++++++++++++ .../quickshell/files/bar/BluetoothControl.qml | 63 ++++++ .../files/bar/VolumeMixerContent.qml | 4 +- roles/quickshell/files/bar/qmldir | 2 + roles/quickshell/tasks/main.yml | 2 + 6 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 roles/quickshell/files/bar/BluetoothContent.qml create mode 100644 roles/quickshell/files/bar/BluetoothControl.qml diff --git a/roles/quickshell/files/bar/Bar.qml b/roles/quickshell/files/bar/Bar.qml index f95ac15..1549bf3 100644 --- a/roles/quickshell/files/bar/Bar.qml +++ b/roles/quickshell/files/bar/Bar.qml @@ -65,6 +65,10 @@ PanelWindow { NetworkStatus { anchors.verticalCenter: parent.verticalCenter } + BluetoothControl { + anchors.verticalCenter: parent.verticalCenter + onClickedLeft: root.activePopup = root.activePopup === "bluetooth" ? "" : "bluetooth" + } VolumeControl { anchors.verticalCenter: parent.verticalCenter onClickedLeft: root.activePopup = root.activePopup === "mixer" ? "" : "mixer" @@ -118,6 +122,28 @@ PanelWindow { } } + // ── Bluetooth popup ─────────────────────────────────────────────── + PopoutWindow { + id: btPopup + popupName: "bluetooth" + activePopup: root.activePopup + + anchor.window: root + anchor.rect.y: root.implicitHeight - root.bw - Theme.radius + readonly property real pw: Math.max(rightRow.width + 2 * root.pad, + btContent.implicitWidth + 2 * root.bw) + anchor.rect.x: root.width - pw + + implicitWidth: pw + implicitHeight: btContent.implicitHeight + root.bw + Theme.radius + + BluetoothContent { + id: btContent + anchors { left: parent.left; right: parent.right } + scanning: btPopup._open + } + } + // ── Volume mixer popup ──────────────────────────────────────────── PopoutWindow { popupName: "mixer" diff --git a/roles/quickshell/files/bar/BluetoothContent.qml b/roles/quickshell/files/bar/BluetoothContent.qml new file mode 100644 index 0000000..9d3c321 --- /dev/null +++ b/roles/quickshell/files/bar/BluetoothContent.qml @@ -0,0 +1,187 @@ +import Quickshell.Bluetooth +import QtQuick +import QtQuick.Layouts +import "../components" + +// Bluetooth panel – a plain Item so it can live inside an expanding section border. +Item { + id: root + + implicitWidth: 300 + implicitHeight: btCol.implicitHeight + 24 + + // Driven by the popup's open state — only scan for nearby devices while visible. + property bool scanning: false + + readonly property var adapter: Bluetooth.defaultAdapter + readonly property bool powered: adapter?.enabled ?? false + + // Run device discovery only while the popup is open and the adapter is on. + Binding { + target: root.adapter + property: "discovering" + value: root.scanning && root.powered + when: root.adapter !== null + } + + // \uf293 = FA bluetooth-b, \uf294 = FA bluetooth + function devIcon(dev) { + return dev.connected ? "\uf293" : "\uf294" + } + + ColumnLayout { + id: btCol + anchors { fill: parent; margins: 12 } + spacing: 4 + + RowLayout { + Layout.fillWidth: true + Layout.bottomMargin: 2 + + Text { + text: "Bluetooth" + color: Theme.textDim + font.pixelSize: 10 + Layout.fillWidth: true + } + Text { + text: root.powered ? "On" : "Off" + font.pixelSize: 10 + color: root.powered ? Theme.accent : Theme.textDim + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled + } + } + } + + Text { + visible: !root.powered + text: "Adapter disabled" + color: Theme.textDim + font.pixelSize: 11 + Layout.fillWidth: true + } + + // ── Paired devices ──────────────────────────────────────────── + Text { + visible: root.powered + text: "Paired" + color: Theme.textDim + font.pixelSize: 10 + Layout.fillWidth: true + Layout.topMargin: 6 + Layout.bottomMargin: 2 + } + + Repeater { + model: Bluetooth.devices + delegate: DeviceRow { + required property var modelData + Layout.fillWidth: true + device: modelData + visible: root.powered && modelData.bonded + } + } + + // ── Available (discoverable) devices ────────────────────────── + RowLayout { + visible: root.powered + Layout.fillWidth: true + Layout.topMargin: 6 + Layout.bottomMargin: 2 + + Text { + text: "Available" + color: Theme.textDim + font.pixelSize: 10 + Layout.fillWidth: true + } + Text { + visible: root.adapter?.discovering ?? false + text: "scanning…" + color: Theme.textDim + font.pixelSize: 10 + } + } + + Repeater { + model: Bluetooth.devices + delegate: DeviceRow { + required property var modelData + Layout.fillWidth: true + device: modelData + // Only named devices — hides the swarm of random-address BLE beacons. + visible: root.powered && !modelData.bonded + && (modelData.deviceName ?? "").length > 0 + } + } + } + + component DeviceRow: RowLayout { + id: drow + required property var device + spacing: 8 + implicitHeight: 28 + + // Once a freshly-paired device bonds, trust it (so it can reconnect + // without an agent) and bring up the connection automatically. + Connections { + target: drow.device + function onBondedChanged() { + if (!drow.device.bonded) return + if (!drow.device.trusted) drow.device.trusted = true + if (!drow.device.connected) drow.device.connect() + } + } + + Text { + text: root.devIcon(drow.device) + font.family: "JetBrainsMono Nerd Font Mono" + font.pixelSize: 13 + color: drow.device.connected ? Theme.accent : Theme.textDim + } + + Text { + text: drow.device.name || drow.device.address || "?" + font.pixelSize: 11 + color: drow.device.connected ? Theme.text : Theme.textDim + Layout.fillWidth: true + elide: Text.ElideRight + } + + Text { + visible: drow.device.batteryAvailable + text: Math.round(drow.device.battery * 100) + "%" + font.pixelSize: 10 + color: Theme.textDim + Layout.preferredWidth: 30 + horizontalAlignment: Text.AlignRight + } + + Text { + text: drow.device.pairing ? "pairing…" + : drow.device.state === BluetoothDeviceState.Connecting ? "…" + : drow.device.state === BluetoothDeviceState.Disconnecting ? "…" + : !drow.device.bonded ? "Pair" + : drow.device.connected ? "Disconnect" + : "Connect" + font.pixelSize: 10 + color: drow.device.connected ? Theme.text : Theme.textDim + Layout.preferredWidth: 70 + horizontalAlignment: Text.AlignRight + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + const d = drow.device + if (d.pairing) d.cancelPair() + else if (!d.bonded) d.pair() + else if (d.connected) d.disconnect() + else d.connect() + } + } + } + } +} diff --git a/roles/quickshell/files/bar/BluetoothControl.qml b/roles/quickshell/files/bar/BluetoothControl.qml new file mode 100644 index 0000000..ace5f40 --- /dev/null +++ b/roles/quickshell/files/bar/BluetoothControl.qml @@ -0,0 +1,63 @@ +import Quickshell.Bluetooth +import QtQuick +import "../components" + +Item { + id: root + implicitWidth: chip.implicitWidth + 10 + implicitHeight: 24 + + signal clickedLeft + + readonly property var adapter: Bluetooth.defaultAdapter + readonly property bool powered: adapter?.enabled ?? false + readonly property var connectedDevices: + Bluetooth.devices.values.filter(d => d.connected) + + // \uf293 = FA bluetooth-b (on), \uf294 = FA bluetooth (off / no connection) + readonly property string btIcon: root.powered ? "\uf293" : "\uf294" + + function label() { + const n = root.connectedDevices.length + if (n === 0) return "" + if (n === 1) { + const name = root.connectedDevices[0].name || "" + return name.length > 16 ? name.substring(0, 15) + "…" : name + } + return n + " devices" + } + + Row { + id: chip + anchors.centerIn: parent + spacing: 5 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.btIcon + font.family: "JetBrainsMono Nerd Font Mono" + font.pixelSize: 14 + color: (root.powered && root.connectedDevices.length > 0) ? Theme.text : Theme.textDim + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.label() + font.pixelSize: 11 + color: Theme.textDim + visible: text !== "" + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + if (root.adapter) root.adapter.enabled = !root.adapter.enabled + } else { + root.clickedLeft() + } + } + } +} diff --git a/roles/quickshell/files/bar/VolumeMixerContent.qml b/roles/quickshell/files/bar/VolumeMixerContent.qml index c289802..d43d0e7 100644 --- a/roles/quickshell/files/bar/VolumeMixerContent.qml +++ b/roles/quickshell/files/bar/VolumeMixerContent.qml @@ -68,8 +68,8 @@ Item { required property var modelData Layout.fillWidth: true node: modelData - visible: modelData.isStream && !modelData.isSink && modelData.ready - && !(modelData.description ?? "").toLowerCase().includes("monitor") + // Application playback streams (sink inputs) report isStream && isSink. + visible: modelData.isStream && modelData.isSink && modelData.ready } } } diff --git a/roles/quickshell/files/bar/qmldir b/roles/quickshell/files/bar/qmldir index 94f9643..e402847 100644 --- a/roles/quickshell/files/bar/qmldir +++ b/roles/quickshell/files/bar/qmldir @@ -1,5 +1,7 @@ Bar 1.0 Bar.qml Battery 1.0 Battery.qml +BluetoothControl 1.0 BluetoothControl.qml +BluetoothContent 1.0 BluetoothContent.qml PopoutWindow 1.0 PopoutWindow.qml CalendarContent 1.0 CalendarContent.qml Clock 1.0 Clock.qml diff --git a/roles/quickshell/tasks/main.yml b/roles/quickshell/tasks/main.yml index 27ce261..8263f22 100644 --- a/roles/quickshell/tasks/main.yml +++ b/roles/quickshell/tasks/main.yml @@ -48,4 +48,6 @@ - { src: bar/VolumeMixerContent.qml, dest: bar/VolumeMixerContent.qml } - { src: bar/Workspaces.qml, dest: bar/Workspaces.qml } - { src: bar/WorkspaceButton.qml, dest: bar/WorkspaceButton.qml } + - { src: bar/BluetoothContent.qml, dest: bar/BluetoothContent.qml } + - { src: bar/BluetoothControl.qml, dest: bar/BluetoothControl.qml } - { src: bar/qmldir, dest: bar/qmldir }