198 lines
7.7 KiB
QML
198 lines
7.7 KiB
QML
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|