import QtQuick import QtQuick.Controls import Quickshell import Quickshell.Io import Quickshell.Bluetooth import Quickshell.Services.Pipewire import qs.Ui import qs.Commons import "Model.js" as Model Panel { id: root moduleName: "omarchy.bluetooth" ipcTarget: "omarchy.bluetooth" // Address -> "connecting" | "disconnecting" | "forgetting". // The actual Bluetooth sequencing lives in bin/omarchy-bluetooth-device; // this map only keeps the panel responsive while BlueZ catches up. property var pendingActions: ({}) readonly property var adapter: Bluetooth.defaultAdapter readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] readonly property var pipewireNodes: Pipewire.nodes ? Pipewire.nodes.values : [] property var pendingAudioOutputDevice: null property int pendingAudioOutputAttempts: 0 function deviceLabel(device) { return Model.deviceLabel(device) } function isUuidLike(value) { return Model.isUuidLike(value) } function isAddressLike(value) { return Model.isAddressLike(value) } function hasHumanName(device) { return Model.hasHumanName(device) } readonly property var deviceGroups: Model.deviceLists(devices) readonly property var connectedDevices: deviceGroups.connected || [] readonly property var knownDevices: deviceGroups.known || [] readonly property var discoveredDevices: deviceGroups.discovered || [] readonly property string icon: { if (!adapter) return "" if (!adapter.enabled) return "󰂲" if (connectedDevices.length > 0) return "󰂱" return "󰂯" } property int phraseIndex: 0 readonly property var activePhrases: [ "Untangling wires", "Streaming vikings", "Pairing mysteries", "Herding headsets", "Taming radios", "Summoning speakers", "Wrangling codecs", "Polishing packets" ] readonly property bool rotatingPhrases: adapter && adapter.enabled readonly property string heroStatusText: { if (!adapter) return "No adapter" if (!adapter.enabled) return "Turned Off" return activePhrases[phraseIndex % activePhrases.length] } // Single cursor model shared by keyboard and mouse. Sections: // "connected" — currently connected devices; Enter disconnects. // "known" — remembered devices; Enter connects. // "discovered" — unremembered devices visible while scanning; Enter connects. // Visuals always come from CursorSurface (hasCursor / current), // never from containsMouse. Mouse hover updates root cursor state too, // guaranteeing one highlight on screen. property string focusSection: "connected" property int selectedIndex: 0 property bool actionFocused: false property bool cursorActive: false // Stable identity for the focused device. Devices move between sections as // they connect, disconnect, pair, or get forgotten, so follow the BlueZ // address across section changes instead of preserving a stale row index. property string focusedDeviceAddress: "" // "header" is a virtual section for the hero Bluetooth on/off toggle; it // sits above the device sections so the adapter can be toggled by keyboard // even when it is off and no device rows exist. readonly property bool headerHasCursor: cursorActive && focusSection === "header" readonly property string toggleHint: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on" readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" function sectionCount(section) { if (section === "connected") return connectedDevices.length if (section === "known") return knownDevices.length if (section === "discovered") return discoveredDevices.length return 0 } function sectionVisible(section) { if (section === "connected") return connectedDevices.length > 0 if (section === "known") return knownDevices.length > 0 if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0 return false } readonly property var visibleSections: { return Model.visibleSections(deviceGroups, adapter && adapter.discovering) } function devicesForSection(section) { return Model.sectionDevices(deviceGroups, section) } function audioSinks() { var sinks = [] for (var i = 0; i < pipewireNodes.length; i++) { var node = pipewireNodes[i] if (node && node.isSink && !node.isStream) sinks.push(node) } return sinks } function bluetoothAudioSink(device) { var sinks = audioSinks() for (var i = 0; i < sinks.length; i++) { if (Model.bluetoothSinkMatchesDevice(sinks[i], device)) return sinks[i] } return null } function setDefaultAudioSink(sink) { if (!sink) return Pipewire.preferredDefaultAudioSink = sink if (sink.id !== undefined && sink.name) { Quickshell.execDetached([ "omarchy-audio-output-set-default", String(sink.id), String(sink.name) ]) } } function scheduleAudioOutputSwitch(device) { pendingAudioOutputDevice = { address: device && device.address ? device.address : "", name: device && device.name ? device.name : "", deviceName: device && device.deviceName ? device.deviceName : "" } pendingAudioOutputAttempts = 0 audioSwitchTimer.restart() } function switchPendingAudioOutput() { if (!pendingAudioOutputDevice) return var sink = bluetoothAudioSink(pendingAudioOutputDevice) if (sink) { setDefaultAudioSink(sink) pendingAudioOutputDevice = null audioSwitchTimer.stop() return } pendingAudioOutputAttempts += 1 if (pendingAudioOutputAttempts >= 8) { pendingAudioOutputDevice = null return } audioSwitchTimer.restart() } function deviceAt(section, index) { var list = devicesForSection(section) return index >= 0 && index < list.length ? list[index] : null } function cloneMap(map) { return Model.cloneMap(map) } function pendingAction(address) { return Model.pendingAction(pendingActions, address) } function setPendingAction(address, action) { if (!address) return pendingActions = Model.withPendingAction(pendingActions, address, action) if (action) pendingTimeout.restart() } function deviceCommand(action, address) { return ["omarchy-bluetooth-device", action, address] } function runDeviceAction(device, action, pending) { if (!device || !device.address) return setPendingAction(device.address, pending) Quickshell.execDetached(deviceCommand(action, device.address)) } function connectDevice(device) { if (!device || device.connected) return if (device.paired || device.bonded || device.trusted) runDeviceAction(device, "connect", "connecting") else runDeviceAction(device, "pair", "connecting") } function disconnectDevice(device) { if (!device || !device.address) return if (!device.connected) return setPendingAction(device.address, "disconnecting") if (device.disconnect) device.disconnect() Quickshell.execDetached(deviceCommand("disconnect", device.address)) } function forgetDevice(device) { if (!device || !device.address) return runDeviceAction(device, "forget", "forgetting") } function syncPendingActions() { var next = cloneMap(pendingActions) var changed = false for (var address in next) { var action = next[address] var found = null for (var i = 0; i < devices.length; i++) { var d = devices[i] if (d && d.address === address) { found = d break } } var finishedConnecting = action === "connecting" && found && found.connected if (finishedConnecting || (action === "disconnecting" && found && !found.connected) || (action === "forgetting" && (!found || (!found.paired && !found.bonded && !found.trusted)))) { if (finishedConnecting) scheduleAudioOutputSwitch(found) delete next[address] changed = true } } if (changed) pendingActions = next } // j/k navigates the hero toggle ("header") and the device sections // row-by-row. function moveCursor(delta) { var sections = visibleSections if (focusSection === "header") { if (delta > 0 && sections && sections.length > 0) { focusSection = sections[0]; selectedIndex = 0; actionFocused = false } return } if (!sections || sections.length === 0) { focusSection = "header"; actionFocused = false; return } var sIdx = sections.indexOf(focusSection) if (sIdx < 0) { focusSection = sections[0]; selectedIndex = 0; actionFocused = false; return } var idx = selectedIndex var max = sectionCount(focusSection) - 1 if (delta > 0) { if (idx < max) { selectedIndex = idx + 1; actionFocused = false; return } if (sIdx < sections.length - 1) { focusSection = sections[sIdx + 1] selectedIndex = 0 actionFocused = false } } else { if (idx > 0) { selectedIndex = idx - 1; actionFocused = false; return } if (sIdx > 0) { focusSection = sections[sIdx - 1] selectedIndex = sectionCount(focusSection) - 1 actionFocused = false } else { focusSection = "header"; actionFocused = false } } } function setHeaderCursor() { cursorActive = true focusSection = "header" actionFocused = false } function moveCursorH(delta) { if (!cursorActive) { cursorActive = true; return } if (focusSection !== "known" && focusSection !== "connected") return var dev = deviceAt(focusSection, selectedIndex) if (!dev || !dev.address) return if (delta > 0) actionFocused = true else if (delta < 0) actionFocused = false } function activateCursor() { if (focusSection === "header") { toggleBluetooth() return } if (actionFocused) { deleteSelected() return } if (focusSection === "connected" || focusSection === "known") { var dev = deviceAt(focusSection, selectedIndex) if (!dev) return if (dev.connected) disconnectDevice(dev) else connectDevice(dev) return } if (focusSection === "discovered") { var d = discoveredDevices[selectedIndex] if (!d) return connectDevice(d) } } // 'x' forgets remembered devices. For connected devices this first // disconnects, then removes the BlueZ pairing record via omarchy-bluetooth-device. function deleteSelected() { if (focusSection !== "known" && focusSection !== "connected") return var dev = deviceAt(focusSection, selectedIndex) if (!dev) return forgetDevice(dev) } onOpenedChanged: { if (opened) { if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 } else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 } else if (discoveredDevices.length > 0) { focusSection = "discovered"; selectedIndex = 0 } else { focusSection = "header" } actionFocused = false cursorActive = false } } function updateFocusedAddress() { var d = deviceAt(focusSection, selectedIndex) focusedDeviceAddress = d ? (d.address || "") : "" } function reselectFocusedDevice() { if (focusedDeviceAddress === "") { clampCursor() return } var sections = ["connected", "known", "discovered"] for (var s = 0; s < sections.length; s++) { var section = sections[s] if (!sectionVisible(section)) continue var list = devicesForSection(section) for (var i = 0; i < list.length; i++) { if (list[i] && list[i].address === focusedDeviceAddress) { focusSection = section selectedIndex = i clampCursor() return } } } clampCursor() } onSelectedIndexChanged: updateFocusedAddress() onFocusSectionChanged: updateFocusedAddress() onConnectedDevicesChanged: { reselectFocusedDevice(); syncPendingActions() } onKnownDevicesChanged: { reselectFocusedDevice(); syncPendingActions() } onDiscoveredDevicesChanged: { reselectFocusedDevice(); syncPendingActions() } onVisibleSectionsChanged: clampCursor() // Keep the keyboard-focused row inside the visible viewport of the device // Flickable. Each DeviceRow calls this when it gains hasCursor. Without // it, j/k can walk the selection off-screen in a long device list. function ensureCursorVisible(item) { if (!item || !deviceFlick) return var pt = item.mapToItem(deviceFlick.contentItem, 0, 0) var top = pt.y var bottom = top + (item.height || 0) var viewTop = deviceFlick.contentY var viewBottom = viewTop + deviceFlick.height var margin = 6 if (top < viewTop + margin) deviceFlick.contentY = Math.max(0, top - margin) else if (bottom > viewBottom - margin) deviceFlick.contentY = bottom + margin - deviceFlick.height } function clampCursor() { var sections = visibleSections // "header" is virtual and never appears in visibleSections, so it has to // be let through: toggling the adapter empties and refills the device // lists, and clamping would knock the cursor off the hero switch every // time it is used. if (focusSection === "header") return if (!sections || !sections.length) { selectedIndex = 0 return } if (sections.indexOf(focusSection) < 0) { focusSection = sections[0] selectedIndex = 0 return } var count = sectionCount(focusSection) if (count === 0) { // Section emptied out — bounce to the previous visible one. var sIdx = sections.indexOf(focusSection) focusSection = sIdx > 0 ? sections[sIdx - 1] : sections[0] selectedIndex = Math.max(0, sectionCount(focusSection) - 1) return } if (selectedIndex > count - 1) selectedIndex = count - 1 if (selectedIndex < 0) selectedIndex = 0 } visible: adapter !== null implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight // BlueZ rejects StartDiscovery while the adapter is still powering up, and // discovery can also time out on its own. While the panel is open, keep // nudging it back on so an enabled adapter is always scanning. Timer { id: discoveryRetry interval: 1000 repeat: true triggeredOnStart: true running: root.opened && root.adapter !== null && root.adapter.enabled && !root.adapter.discovering onTriggered: root.adapter.discovering = true } Timer { id: pendingTimeout interval: 20000 repeat: false onTriggered: root.pendingActions = ({}) } Timer { id: audioSwitchTimer interval: 500 repeat: false onTriggered: root.switchPendingAudioOutput() } Timer { id: phraseTimer interval: 2800 running: root.opened && root.rotatingPhrases repeat: true onTriggered: phraseSwap.restart() } SequentialAnimation { id: phraseSwap PropertyAnimation { target: heroStatus; property: "opacity" to: 0.0; duration: 180; easing.type: Easing.OutQuad } ScriptAction { script: root.phraseIndex = (root.phraseIndex + 1) % root.activePhrases.length } PropertyAnimation { target: heroStatus; property: "opacity" to: 1.0; duration: 260; easing.type: Easing.InQuad } } Connections { target: root function onRotatingPhrasesChanged() { if (!root.rotatingPhrases) { phraseSwap.stop() heroStatus.opacity = 1.0 } } } function toggleBluetooth() { if (!adapter) return adapter.enabled = !adapter.enabled } BarIconButton { id: button anchors.fill: parent bar: root.bar text: root.icon onPressed: function(b) { if (b === Qt.RightButton) root.toggleBluetooth() else root.toggle() } } KeyboardPanel { id: panel anchorItem: button owner: root bar: root.bar open: root.opened focusTarget: keyCatcher contentWidth: panel.fittedContentWidth(Style.space(380)) contentHeight: panel.fittedContentHeight(column.implicitHeight) PanelKeyCatcher { id: keyCatcher anchors.fill: parent onMoveRequested: function(dx, dy) { if (!root.cursorActive) { root.cursorActive = true; return } if (dy !== 0) root.moveCursor(dy) else if (dx !== 0) root.moveCursorH(dx) } onActivateRequested: if (root.cursorActive) root.activateCursor() onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } onDeleteRequested: if (root.cursorActive) root.deleteSelected() onTextKey: function(t) { if (t === "b" || t === "B") root.toggleBluetooth() } Column { id: column anchors.fill: parent spacing: Style.space(14) // ---------- Hero: Bluetooth icon · status ---------- Item { width: parent.width implicitHeight: Math.max(heroIcon.implicitHeight, heroLabels.implicitHeight, powerSwitch.implicitHeight) // Status only — the switch owns toggling, mouse and keyboard alike. Text { id: heroIcon anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: root.icon color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.display opacity: root.adapter && root.adapter.enabled ? 1.0 : 0.5 } // Compact on/off switch on the trailing edge of the hero, and the // header's only cursor target. ToggleSwitch { id: powerSwitch visible: !!root.adapter checked: !!root.adapter && root.adapter.enabled hasCursor: root.headerHasCursor foreground: root.bar.foreground anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter onHovered: function(on) { if (on) root.setHeaderCursor() } onToggled: root.toggleBluetooth() PanelToolTip { visible: powerSwitch.containsMouse text: root.toggleHint fontFamily: root.bar.fontFamily } } Column { id: heroLabels anchors.left: heroIcon.right anchors.leftMargin: Style.space(14) anchors.right: parent.right anchors.rightMargin: powerSwitch.visible ? powerSwitch.width + Style.space(12) : 0 anchors.verticalCenter: parent.verticalCenter spacing: Style.space(2) Text { text: "Bluetooth" color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.title font.bold: true elide: Text.ElideRight width: parent.width } Text { id: heroStatus text: root.heroStatusText.toUpperCase() color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption font.bold: true font.letterSpacing: 1.2 elide: Text.ElideRight width: parent.width } } } // Scrollable device list — capped so a noisy neighborhood doesn't // grow the popup past the screen. PanelSeparator { foreground: root.bar.foreground } Column { id: connectedList visible: root.connectedDevices.length > 0 width: parent.width spacing: Style.space(10) PanelSectionHeader { text: "CONNECTED" foreground: root.bar.foreground fontFamily: root.bar.fontFamily } Repeater { model: root.connectedDevices DeviceRow { required property var modelData required property int index width: connectedList.width dev: modelData rowIndex: index sectionName: "connected" isDiscovered: false } } } PanelSeparator { visible: root.connectedDevices.length > 0 && (root.knownDevices.length > 0 || (root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0)) foreground: root.bar.foreground } Flickable { id: deviceFlick width: parent.width height: Math.min(deviceList.implicitHeight, Style.space(400)) contentWidth: width contentHeight: deviceList.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds interactive: contentHeight > height ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Column { id: deviceList width: parent.width spacing: Style.space(10) // Remembered devices. PanelSectionHeader { visible: root.knownDevices.length > 0 text: "PAIRED" foreground: root.bar.foreground fontFamily: root.bar.fontFamily } Repeater { model: root.knownDevices DeviceRow { required property var modelData required property int index width: deviceList.width dev: modelData rowIndex: index sectionName: "known" isDiscovered: false } } // Discovered (unpaired) devices, only shown while scanning. PanelSeparator { visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 && root.knownDevices.length > 0 foreground: root.bar.foreground } PanelSectionHeader { visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 text: "AVAILABLE" foreground: root.bar.foreground fontFamily: root.bar.fontFamily } Repeater { model: root.adapter && root.adapter.discovering ? root.discoveredDevices : [] DeviceRow { required property var modelData required property int index width: deviceList.width dev: modelData rowIndex: index sectionName: "discovered" isDiscovered: true } } Text { visible: root.connectedDevices.length === 0 && root.knownDevices.length === 0 && (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0) text: !root.adapter ? "No Bluetooth adapter" : !root.adapter.enabled ? "Turn Bluetooth on to scan" : "Scanning for devices…" color: Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.bodySmall wrapMode: Text.WordWrap width: deviceList.width } } } } } } // Two-line device row showing name + live status. Pending state is owned // by the panel so it survives rows moving between sections. component DeviceRow: CursorSurface { id: row required property var dev required property int rowIndex required property string sectionName required property bool isDiscovered readonly property bool isConnected: dev && dev.connected readonly property int devState: dev && dev.state !== undefined ? dev.state : -1 readonly property string action: root.pendingAction(dev ? dev.address : "") readonly property string actionTooltip: { if (!dev) return "" if (isConnected) return "Disconnect" if (isDiscovered) return "Pair" return "Connect" } readonly property bool rowSelected: root.cursorActive && root.focusSection === sectionName && root.selectedIndex === rowIndex readonly property bool forgetAvailable: (sectionName === "known" || sectionName === "connected") && !isDiscovered readonly property bool showForgetButton: forgetAvailable && (rowMouse.containsMouse || rowSelected) hasCursor: rowSelected && !root.actionFocused onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row) current: isConnected foreground: root.bar.foreground fill: root.hoverFill currentFill: root.selectedFill readonly property string statusText: { if (!dev) return "" if (action === "forgetting") return "Forgetting…" if (action === "disconnecting" || devState === 2) return "Disconnecting…" if (isConnected) { if (dev.batteryAvailable) return Math.round(dev.battery * 100) + "%" return sectionName === "connected" ? "" : "Connected" } if (action === "connecting" || devState === 3 || dev.pairing === true) return "Connecting…" if (isDiscovered) return "" return "" } readonly property color statusColor: { if (isConnected) return root.bar.foreground if (action !== "" || devState === 3 || dev.pairing === true) return root.bar.foreground return Qt.darker(root.bar.foreground, 1.5) } implicitHeight: rowContent.implicitHeight + Style.spacing.rowPaddingX MouseArea { id: rowMouse anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.RightButton cursorShape: row.dev ? Qt.PointingHandCursor : Qt.ArrowCursor onContainsMouseChanged: if (containsMouse) { root.cursorActive = true root.focusSection = row.sectionName root.selectedIndex = row.rowIndex root.actionFocused = false } onClicked: function(mouse) { if (!row.dev) return if (mouse.button === Qt.RightButton) { if (row.isConnected) root.disconnectDevice(row.dev) else if (!row.isDiscovered) root.forgetDevice(row.dev) return } if (row.isConnected) root.disconnectDevice(row.dev) else root.connectDevice(row.dev) } } PanelToolTip { visible: row.actionTooltip !== "" && rowMouse.containsMouse && !root.actionFocused text: row.actionTooltip fontFamily: root.bar.fontFamily } Item { id: rowContent anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.space(10) anchors.rightMargin: Style.space(10) implicitHeight: Math.max(deviceIcon.implicitHeight, info.implicitHeight, forgetBtn.implicitHeight) Text { id: deviceIcon text: row.isConnected ? "󰂱" : "󰂯" color: row.statusColor font.family: root.bar.fontFamily font.pixelSize: Style.font.heading anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter } Column { id: info spacing: Style.space(1) anchors.left: deviceIcon.right anchors.leftMargin: Style.space(10) anchors.right: forgetBtn.visible ? forgetBtn.left : parent.right anchors.rightMargin: forgetBtn.visible ? Style.space(8) : 0 anchors.verticalCenter: parent.verticalCenter Text { text: root.deviceLabel(row.dev) || "Device" color: root.bar.foreground font.family: root.bar.fontFamily font.pixelSize: Style.font.body elide: Text.ElideRight width: parent.width } Text { visible: row.statusText !== "" text: row.statusText color: row.statusColor font.family: root.bar.fontFamily font.pixelSize: Style.font.caption elide: Text.ElideRight width: parent.width } } PanelActionButton { id: forgetBtn anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: row.showForgetButton iconText: "󰅙" tooltipText: "Forget" foreground: root.bar.foreground hoverColor: root.bar.foreground fontFamily: root.bar.fontFamily hasCursor: row.rowSelected && root.actionFocused onHovered: function(isHovered) { if (!isHovered) { if (rowMouse.containsMouse) root.actionFocused = false return } root.cursorActive = true root.focusSection = row.sectionName root.selectedIndex = row.rowIndex root.actionFocused = true } onClicked: { if (!row.dev) return root.forgetDevice(row.dev) } } } } }