Files
att_menu/modules/BluetoothPanel.qml
T
2026-07-26 21:16:55 +02:00

242 lines
7.5 KiB
QML

import Quickshell
import Quickshell.Wayland
import Quickshell.Bluetooth
import QtQuick
import qs.config
import qs.components
import qs.services
// The bluetooth menu: adapter power and the devices bluetoothd already knows
// about. Same full-screen layer surface as the launcher, so click-away and
// Escape work on any wlroots compositor.
//
// Deliberately not a pairing UI — discovery is never turned on, so nothing
// transient appears in the list and the panel stays a one-click connect.
PanelWindow {
id: root
required property var bar
readonly property string screenName: root.bar.screen ? root.bar.screen.name : ""
visible: Session.isOpen("bluetooth", root.screenName)
screen: root.bar.screen
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: Config.namespace + "-bluetooth"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
anchors {
left: true
right: true
top: true
bottom: true
}
exclusionMode: ExclusionMode.Ignore
color: "transparent"
// ---- Data ------------------------------------------------------------
readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
readonly property bool adapterOn: root.adapter ? root.adapter.enabled : false
readonly property bool adapterBusy: root.adapter ? root.adapter.state === BluetoothAdapterState.Enabling || root.adapter.state === BluetoothAdapterState.Disabling : false
// Known devices only: paired or bonded, i.e. what bluetoothd remembers.
// Connected ones first, then alphabetical, so the list does not reshuffle
// under the cursor for any other reason.
readonly property var devices: {
if (!root.adapter)
return [];
return [...root.adapter.devices.values].filter(d => d.paired || d.bonded).sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
return (a.name || "").localeCompare(b.name || "");
});
}
function toggleDevice(device: BluetoothDevice): void {
if (!device)
return;
if (device.connected)
device.disconnect();
else
device.connect();
}
onVisibleChanged: {
if (root.visible) {
list.currentIndex = 0;
keyHandler.forceActiveFocus();
}
}
// ---- Chrome ----------------------------------------------------------
// Click-away scrim.
MouseArea {
anchors.fill: parent
onClicked: Session.close()
}
Item {
id: keyHandler
anchors.fill: parent
focus: true
Keys.onEscapePressed: Session.close()
Keys.onDownPressed: list.currentIndex = Math.min(list.currentIndex + 1, list.count - 1)
Keys.onUpPressed: list.currentIndex = Math.max(list.currentIndex - 1, 0)
Keys.onReturnPressed: root.toggleDevice(root.devices[list.currentIndex])
Keys.onEnterPressed: root.toggleDevice(root.devices[list.currentIndex])
}
Rectangle {
id: menu
// Anchored to the panel's right edge, under the button that opens it.
anchors {
right: parent.right
bottom: parent.bottom
rightMargin: Theme.padding
bottomMargin: Theme.barHeight + Theme.padding
}
readonly property int maxHeight: Math.min(Config.bluetoothMenuHeight, root.height - Theme.barHeight - Theme.padding * 2)
readonly property int bodyHeight: root.adapterOn && list.count > 0 ? list.contentHeight : 48
width: Math.min(Config.bluetoothMenuWidth, root.width - Theme.padding * 2)
height: Math.min(menu.maxHeight, Theme.padding * 4 + header.height + Theme.padding * 2 + menu.bodyHeight)
color: Theme.surface
radius: Theme.radius * 2
border.width: Theme.borderWidth
border.color: Theme.border
// Swallow clicks so they do not reach the scrim behind.
MouseArea {
anchors.fill: parent
}
Item {
id: header
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.padding * 2
}
height: 24
BluetoothGlyph {
id: headerGlyph
anchors.verticalCenter: parent.verticalCenter
color: root.adapterOn ? Theme.text : Theme.textDim
struck: !root.adapterOn
size: 18
}
Text {
anchors {
left: headerGlyph.right
right: toggle.left
leftMargin: Theme.padding
rightMargin: Theme.padding
verticalCenter: parent.verticalCenter
}
elide: Text.ElideRight
text: "Bluetooth"
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: true
}
ToggleSwitch {
id: toggle
anchors {
right: parent.right
verticalCenter: parent.verticalCenter
}
checked: root.adapterOn
busy: root.adapterBusy
enabled: root.adapter !== null
// BlueZ reports the new state back, so `checked` stays bound to
// it rather than to what was clicked.
onToggled: on => {
if (root.adapter)
root.adapter.enabled = on;
}
}
}
Rectangle {
id: separator
anchors {
left: parent.left
right: parent.right
top: header.bottom
topMargin: Theme.padding * 2
}
height: Theme.borderWidth
color: Theme.border
}
ListView {
id: list
anchors {
left: parent.left
right: parent.right
top: separator.bottom
bottom: parent.bottom
margins: Theme.padding
topMargin: Theme.padding
}
visible: root.adapterOn
clip: true
spacing: 1
boundsBehavior: Flickable.StopAtBounds
keyNavigationEnabled: false
model: root.devices
delegate: BluetoothDeviceEntry {
required property var modelData
required property int index
device: modelData
width: list.width
selected: list.currentIndex === index
onActivated: root.toggleDevice(modelData)
onEntered: list.currentIndex = index
}
}
Text {
anchors {
left: parent.left
right: parent.right
top: separator.bottom
bottom: parent.bottom
margins: Theme.padding * 2
}
visible: !root.adapterOn || list.count === 0
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
wrapMode: Text.WordWrap
text: !root.adapter ? "No bluetooth adapter" : !root.adapterOn ? "Bluetooth is off" : "No paired devices"
color: Theme.textDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
}