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 } } }