// 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 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 property bool discoveryOwned: false 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; } function syncDiscovery(): void { if (!root.adapter) return; const shouldDiscover = root.active && root.adapter.enabled; if (shouldDiscover && !root.adapter.discovering) { root.adapter.discovering = true; root.discoveryOwned = true; } else if (!shouldDiscover && root.discoveryOwned && root.adapter.discovering) { root.adapter.discovering = false; root.discoveryOwned = false; } } onActiveChanged: root.syncDiscovery() onAdapterChanged: { if (root.active) root.syncDiscovery(); } Component.onDestruction: { if (root.adapter && root.discoveryOwned && root.adapter.discovering) root.adapter.discovering = false; } 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() } } } } }