// 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; } // Forgetting is armed in place rather than through ConfirmAction: a 44px // row inside a 300px popup has no width for a Keep/Forget it pair beside // the device name, so the trash glyph arms itself and the row's own // sublabel becomes the question. The token is ShellState's, the same one // ConfirmAction uses, so arming here disarms whatever was armed elsewhere // and only one confirm is ever live app-wide. // // The token is keyed by device address rather than held on the delegate, // because discovery rebuilds this list -- and every delegate in it -- every // time BlueZ reports something new. An arm parked on the row object would // be dropped by the next scan result; keyed by address it survives the // rebuild and is only ever cleared by a press or by the section closing. readonly property string forgetPrefix: "bluetooth-forget-quick:" function forgetId(device): string { return root.forgetPrefix + (device.address || device.name || ""); } function disarmForget(): void { if (ShellState.armedConfirm.indexOf(root.forgetPrefix) === 0) ShellState.armedConfirm = ""; } onActiveChanged: { if (root.active) { Connectivity.acquireDiscovery(root.scanHold); } else { Connectivity.releaseDiscovery(root.scanHold); root.disarmForget(); } } 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 readonly property bool forgetArmed: ShellState.armedConfirm === root.forgetId(deviceRow.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 // Armed, the row states the question and what it costs, since // the glyph alone is too small to carry either. sublabel: deviceRow.forgetArmed ? "Press again to drop the pairing" : root.stateText(deviceRow.modelData) selected: deviceRow.modelData.connected // While armed the row body is the way out: a press anywhere // else on it takes the arming back instead of connecting, so // the escape is the largest target on screen. onClicked: { if (deviceRow.forgetArmed) { root.disarmForget(); return; } root.activate(deviceRow.modelData); } // Forgetting is destructive enough that it gets its own // control rather than sharing the row click -- and its own // two presses. See root.forgetPrefix for why the arming // happens in place here rather than through ConfirmAction. IconButton { anchors.verticalCenter: parent.verticalCenter visible: deviceRow.modelData.paired size: 26 iconSize: 13 icon: "user-trash-symbolic" iconFallback: "window-close-symbolic" tint: deviceRow.forgetArmed ? Theme.danger : Theme.fg onClicked: { if (!deviceRow.forgetArmed) { ShellState.armedConfirm = root.forgetId(deviceRow.modelData); return; } root.disarmForget(); deviceRow.modelData.forget(); } // Behind the parent's own fill, so the hover tint still // reads on top of it. Danger appears only once armed -- // the first press must not look like the last one. Rectangle { anchors.fill: parent z: -1 radius: parent.radius visible: deviceRow.forgetArmed color: Theme.alpha(Theme.danger, 0.3) border.width: 1 border.color: Theme.alpha(Theme.danger, 0.6) } } } } } }