// Bluetooth device picker. Paired devices first (that's what you actually // reconnect to), then whatever discovery has turned up. import QtQuick import Quickshell import Quickshell.Widgets import Quickshell.Bluetooth import qs.config import qs.services Item { id: root // True while the section is open. Discovery is expensive, so it only runs // while the list is on screen — same as the GNOME panel. property bool active: false property alias maxHeight: list.maxHeight readonly property var adapter: Bluetooth.defaultAdapter // Discovery is held, not switched. This picker and the settings page both // list the same adapter, and each writing adapter.discovering from its own // visibility flag meant the last one to change its mind decided for both -- // closing the settings page stopped discovery under this panel, which then // said "Searching…" over a radio that had stopped. Connectivity counts the // holds, owns the BlueZ write, and never stops a scan it did not start; // nothing here touches the adapter. readonly property string scanHold: "quicksettings-bluetooth" implicitHeight: list.implicitHeight readonly property var devices: { const list = Bluetooth.devices.values.slice(); list.sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; if (a.paired !== b.paired) return a.paired ? -1 : 1; return (a.name || "").localeCompare(b.name || ""); }); return list; } onActiveChanged: { if (root.active) Connectivity.acquireDiscovery(root.scanHold); else Connectivity.releaseDiscovery(root.scanHold); } Component.onDestruction: Connectivity.releaseDiscovery(root.scanHold) function stateText(device): string { if (device.pairing) return "Pairing…"; switch (device.state) { case BluetoothDeviceState.Connecting: return "Connecting…"; case BluetoothDeviceState.Disconnecting: return "Disconnecting…"; case BluetoothDeviceState.Connected: return device.batteryAvailable ? "Connected · " + Math.round(device.battery * 100) + "%" : "Connected"; default: return device.paired ? "Paired" : ""; } } // Click cycles the obvious next step: pair, connect, disconnect. function activate(device): void { if (device.connected) device.disconnect(); else if (device.paired) device.connect(); else device.pair(); } ScrollColumn { id: list anchors.fill: parent maxHeight: 300 Text { width: parent.width visible: root.devices.length === 0 topPadding: 12 bottomPadding: 12 horizontalAlignment: Text.AlignHCenter text: { if (!root.adapter) return "No Bluetooth adapter"; if (!root.adapter.enabled) return "Bluetooth is off"; return "Searching…"; } color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSize } Repeater { model: root.devices RowButton { id: deviceRow required property var modelData width: parent.width // BlueZ reports a plain freedesktop name ("audio-headphones"); // the symbolic variant is the one that can be recolored. icon: deviceRow.modelData.icon !== "" ? deviceRow.modelData.icon + "-symbolic" : "bluetooth-symbolic" iconFallback: "bluetooth-symbolic" label: deviceRow.modelData.name || deviceRow.modelData.address sublabel: root.stateText(deviceRow.modelData) selected: deviceRow.modelData.connected onClicked: root.activate(deviceRow.modelData) // Forgetting is destructive enough that it gets its own // control rather than sharing the row click. IconButton { anchors.verticalCenter: parent.verticalCenter visible: deviceRow.modelData.paired size: 26 iconSize: 13 icon: "user-trash-symbolic" iconFallback: "window-close-symbolic" onClicked: deviceRow.modelData.forget() } } } } }