Simplify Bluetooth device actions

This commit is contained in:
David Heinemeier Hansson
2026-05-21 11:29:55 +02:00
parent d9622d6b99
commit 25c3752051
3 changed files with 257 additions and 147 deletions
+1
View File
@@ -29,6 +29,7 @@ declare -A GROUP_DESCRIPTIONS
GROUP_DESCRIPTIONS[ac]="AC power detection" GROUP_DESCRIPTIONS[ac]="AC power detection"
GROUP_DESCRIPTIONS[audio]="Audio input and output controls" GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
GROUP_DESCRIPTIONS[battery]="Battery status helpers" GROUP_DESCRIPTIONS[battery]="Battery status helpers"
GROUP_DESCRIPTIONS[bluetooth]="Bluetooth device controls"
GROUP_DESCRIPTIONS[branch]="Omarchy git branch management" GROUP_DESCRIPTIONS[branch]="Omarchy git branch management"
GROUP_DESCRIPTIONS[branding]="About and screensaver branding" GROUP_DESCRIPTIONS[branding]="About and screensaver branding"
GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness" GROUP_DESCRIPTIONS[brightness]="Display and keyboard brightness"
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# omarchy:summary=Control a Bluetooth device
# omarchy:group=bluetooth
# omarchy:args=[pair|connect|disconnect|forget] <address>
# omarchy:examples=omarchy bluetooth device connect 00:11:22:33:44:55
set -e
usage() {
echo "Usage: omarchy-bluetooth-device [pair|connect|disconnect|forget] <address>" >&2
exit 1
}
action=${1:-}
address=${2:-}
[[ $action == "pair" || $action == "connect" || $action == "disconnect" || $action == "forget" ]] || usage
[[ $address =~ ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ ]] || usage
power_on() {
bluetoothctl power on >/dev/null 2>&1 || true
sleep 0.5
}
trust_device() {
bluetoothctl trust "$address" >/dev/null 2>&1 || true
}
case "$action" in
pair)
power_on
timeout 20s bluetoothctl pair "$address" >/dev/null 2>&1 || true
trust_device
timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true
;;
connect)
power_on
trust_device
timeout 20s bluetoothctl connect "$address" >/dev/null 2>&1 || true
;;
disconnect)
timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true
;;
forget)
power_on
timeout 10s bluetoothctl disconnect "$address" >/dev/null 2>&1 || true
timeout 10s bluetoothctl remove "$address" >/dev/null 2>&1 || true
;;
esac
+206 -147
View File
@@ -11,11 +11,10 @@ Panel {
moduleName: "BluetoothPanel" moduleName: "BluetoothPanel"
ipcTarget: "panels.bluetooth" ipcTarget: "panels.bluetooth"
// Address -> true while we are waiting for a click-initiated pair to land // Address -> "connecting" | "disconnecting" | "forgetting".
// so we can chain trust + connect at root scope. Doing this in the row's // The actual Bluetooth sequencing lives in bin/omarchy-bluetooth-device;
// Connections is racy: the discovered Repeater destroys the delegate the // this map only keeps the panel responsive while BlueZ catches up.
// moment `paired` flips, before the row's handler reliably fires. property var pendingActions: ({})
property var pendingPairAddresses: ({})
readonly property var adapter: Bluetooth.defaultAdapter readonly property var adapter: Bluetooth.defaultAdapter
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
@@ -46,8 +45,13 @@ Panel {
readonly property var connectedDevices: { readonly property var connectedDevices: {
var list = [] var list = []
for (var i = 0; i < devices.length; i++) for (var i = 0; i < devices.length; i++) {
if (devices[i] && devices[i].connected && hasHumanName(devices[i])) list.push(devices[i]) var d = devices[i]
if (d && d.connected && hasHumanName(d)) list.push(d)
}
list.sort(function(a, b) {
return deviceLabel(a).localeCompare(deviceLabel(b))
})
return list return list
} }
@@ -55,10 +59,9 @@ Panel {
var list = [] var list = []
for (var i = 0; i < devices.length; i++) { for (var i = 0; i < devices.length; i++) {
var d = devices[i] var d = devices[i]
if (d && hasHumanName(d) && (d.paired || d.connected || d.bonded || d.trusted)) list.push(d) if (d && hasHumanName(d) && !d.connected && (d.paired || d.bonded || d.trusted)) list.push(d)
} }
list.sort(function(a, b) { list.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
return deviceLabel(a).localeCompare(deviceLabel(b)) return deviceLabel(a).localeCompare(deviceLabel(b))
}) })
return list return list
@@ -69,7 +72,7 @@ Panel {
for (var i = 0; i < devices.length; i++) { for (var i = 0; i < devices.length; i++) {
var d = devices[i] var d = devices[i]
if (!d || !hasHumanName(d)) continue if (!d || !hasHumanName(d)) continue
if (d.paired || d.connected || d.bonded || d.trusted) continue if (d.connected || d.paired || d.bonded || d.trusted) continue
list.push(d) list.push(d)
} }
list.sort(function(a, b) { list.sort(function(a, b) {
@@ -88,8 +91,9 @@ Panel {
// Single cursor model shared by keyboard and mouse. Sections: // Single cursor model shared by keyboard and mouse. Sections:
// "header" — 2 action pills (scan, toggle); h/l moves between // "header" — 2 action pills (scan, toggle); h/l moves between
// them, Enter activates. // them, Enter activates.
// "known" — paired/known device rows; Enter toggles connect. // "connected" — currently connected devices; Enter disconnects.
// "discovered" — unpaired devices visible while scanning; Enter pairs. // "known" — remembered devices; Enter connects.
// "discovered" — unremembered devices visible while scanning; Enter connects.
// Visuals always come from CursorSurface (hasCursor / current), // Visuals always come from CursorSurface (hasCursor / current),
// never from containsMouse. Mouse hover updates root cursor state too, // never from containsMouse. Mouse hover updates root cursor state too,
// guaranteeing one highlight on screen. // guaranteeing one highlight on screen.
@@ -98,11 +102,10 @@ Panel {
property bool cursorActive: false property bool cursorActive: false
readonly property int headerPillCount: 2 readonly property int headerPillCount: 2
// Stable identity for the focused known device. The known list is sorted // Stable identity for the focused device. Devices move between sections as
// (connected-first, then alphabetical) so activating a device can shift // they connect, disconnect, pair, or get forgotten, so follow the BlueZ
// its index. We track the BlueZ address here so the cursor follows the // address across section changes instead of preserving a stale row index.
// same device across reorders rather than the slot it used to occupy. property string focusedDeviceAddress: ""
property string focusedKnownAddress: ""
readonly property color hoverFill: bar readonly property color hoverFill: bar
? Style.hoverFillFor(bar.foreground, Color.accent) ? Style.hoverFillFor(bar.foreground, Color.accent)
@@ -113,6 +116,7 @@ Panel {
function sectionCount(section) { function sectionCount(section) {
if (section === "header") return headerPillCount if (section === "header") return headerPillCount
if (section === "connected") return connectedDevices.length
if (section === "known") return knownDevices.length if (section === "known") return knownDevices.length
if (section === "discovered") return discoveredDevices.length if (section === "discovered") return discoveredDevices.length
return 0 return 0
@@ -120,6 +124,7 @@ Panel {
function sectionVisible(section) { function sectionVisible(section) {
if (section === "header") return true if (section === "header") return true
if (section === "connected") return connectedDevices.length > 0
if (section === "known") return knownDevices.length > 0 if (section === "known") return knownDevices.length > 0
if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0 if (section === "discovered") return adapter && adapter.discovering && discoveredDevices.length > 0
return false return false
@@ -127,11 +132,102 @@ Panel {
readonly property var visibleSections: { readonly property var visibleSections: {
var list = ["header"] var list = ["header"]
if (sectionVisible("connected")) list.push("connected")
if (sectionVisible("known")) list.push("known") if (sectionVisible("known")) list.push("known")
if (sectionVisible("discovered")) list.push("discovered") if (sectionVisible("discovered")) list.push("discovered")
return list return list
} }
function devicesForSection(section) {
if (section === "connected") return connectedDevices
if (section === "known") return knownDevices
if (section === "discovered") return discoveredDevices
return []
}
function deviceAt(section, index) {
var list = devicesForSection(section)
return index >= 0 && index < list.length ? list[index] : null
}
function cloneMap(map) {
var next = ({})
for (var key in map) next[key] = map[key]
return next
}
function pendingAction(address) {
return address && pendingActions[address] ? pendingActions[address] : ""
}
function setPendingAction(address, action) {
if (!address) return
var next = cloneMap(pendingActions)
if (action) next[address] = action
else delete next[address]
pendingActions = next
if (action) pendingTimeout.restart()
}
function deviceCommand(action, address) {
var command = root.bar && root.bar.omarchyPath
? root.bar.omarchyPath + "/bin/omarchy-bluetooth-device"
: "omarchy-bluetooth-device"
return [command, 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
}
}
if ((action === "connecting" && found && found.connected)
|| (action === "disconnecting" && found && !found.connected)
|| (action === "forgetting" && (!found || (!found.paired && !found.bonded && !found.trusted)))) {
delete next[address]
changed = true
}
}
if (changed) pendingActions = next
}
// j/k navigates between sections row-by-row. The header is treated as a // j/k navigates between sections row-by-row. The header is treated as a
// SINGLE row (its pills sit on one horizontal line), so j/k from devices // SINGLE row (its pills sit on one horizontal line), so j/k from devices
// jumps to/from the header as a unit, and h/l moves between the three // jumps to/from the header as a unit, and h/l moves between the three
@@ -185,19 +281,17 @@ Panel {
} }
return return
} }
if (focusSection === "known") { if (focusSection === "connected" || focusSection === "known") {
var dev = knownDevices[selectedIndex] var dev = deviceAt(focusSection, selectedIndex)
if (!dev) return if (!dev) return
if (!dev.trusted) dev.trusted = true if (dev.connected) disconnectDevice(dev)
if (dev.connected) dev.disconnect() else connectDevice(dev)
else dev.connect()
return return
} }
if (focusSection === "discovered") { if (focusSection === "discovered") {
var d = discoveredDevices[selectedIndex] var d = discoveredDevices[selectedIndex]
if (!d) return if (!d) return
pendingPairAddresses[d.address] = true connectDevice(d)
d.pair()
} }
} }
@@ -207,52 +301,61 @@ Panel {
// tooltip says "Disconnect" for connected rows, and the keybind has // tooltip says "Disconnect" for connected rows, and the keybind has
// to agree. // to agree.
function deleteSelected() { function deleteSelected() {
if (focusSection !== "known") return if (focusSection !== "connected" && focusSection !== "known") return
var dev = knownDevices[selectedIndex] var dev = deviceAt(focusSection, selectedIndex)
if (!dev) return if (!dev) return
if (dev.connected) dev.disconnect() if (dev.connected) disconnectDevice(dev)
else if (dev.forget) dev.forget() else forgetDevice(dev)
} }
onOpenedChanged: { onOpenedChanged: {
if (opened) { if (opened) {
if (adapter && adapter.enabled && !adapter.discovering) adapter.discovering = true if (adapter && adapter.enabled && !adapter.discovering) adapter.discovering = true
if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 } if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 }
else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 }
else { focusSection = "header"; selectedIndex = 1 } else { focusSection = "header"; selectedIndex = 1 }
cursorActive = false cursorActive = false
} }
} }
// When `selectedIndex` changes inside the known section, remember which function updateFocusedAddress() {
// address it points at. Updates from re-resolution (below) are idempotent if (focusSection === "header") {
// because we end up setting the same address. focusedDeviceAddress = ""
onSelectedIndexChanged: { return
if (focusSection !== "known") return }
if (selectedIndex < 0 || selectedIndex >= knownDevices.length) return var d = deviceAt(focusSection, selectedIndex)
var d = knownDevices[selectedIndex] focusedDeviceAddress = d ? (d.address || "") : ""
focusedKnownAddress = d ? (d.address || "") : ""
} }
onFocusSectionChanged: { function reselectFocusedDevice() {
if (focusSection !== "known") focusedKnownAddress = "" if (focusedDeviceAddress === "") {
} clampCursor()
return
}
onKnownDevicesChanged: { var sections = ["connected", "known", "discovered"]
// Try to follow the device by address before clamping. If we can't find for (var s = 0; s < sections.length; s++) {
// the address (e.g. it was forgotten), fall through to clampCursor() var section = sections[s]
// which will pull selectedIndex back into range. if (!sectionVisible(section)) continue
if (focusSection === "known" && focusedKnownAddress !== "") { var list = devicesForSection(section)
for (var i = 0; i < knownDevices.length; i++) { for (var i = 0; i < list.length; i++) {
if (knownDevices[i] && knownDevices[i].address === focusedKnownAddress) { if (list[i] && list[i].address === focusedDeviceAddress) {
if (selectedIndex !== i) selectedIndex = i focusSection = section
selectedIndex = i
clampCursor() clampCursor()
return return
} }
} }
} }
clampCursor() clampCursor()
} }
onDiscoveredDevicesChanged: clampCursor()
onSelectedIndexChanged: updateFocusedAddress()
onFocusSectionChanged: updateFocusedAddress()
onConnectedDevicesChanged: { reselectFocusedDevice(); syncPendingActions() }
onKnownDevicesChanged: { reselectFocusedDevice(); syncPendingActions() }
onDiscoveredDevicesChanged: { reselectFocusedDevice(); syncPendingActions() }
onVisibleSectionsChanged: clampCursor() onVisibleSectionsChanged: clampCursor()
// Keep the keyboard-focused row inside the visible viewport of the device // Keep the keyboard-focused row inside the visible viewport of the device
@@ -303,29 +406,11 @@ Panel {
} }
} }
// Non-visual lifecycle watchers, one per device. Survives popup open/close Timer {
// and the discovered-known transition that destroys row delegates. id: pendingTimeout
Repeater { interval: 20000
model: root.devices repeat: false
Item { onTriggered: root.pendingActions = ({})
required property var modelData
visible: false
Connections {
target: modelData || null
function onPairedChanged() {
var d = modelData
if (!d || !d.paired) return
if (!root.pendingPairAddresses[d.address]) return
delete root.pendingPairAddresses[d.address]
// BlueZ pair() does not auto-trust or auto-connect. Without
// trusted, the daemon may drop the entry shortly after pairing,
// which makes a freshly-paired device flash "Connected" and then
// vanish from the model.
d.trusted = true
if (!d.connected) d.connect()
}
}
}
} }
WidgetButton { WidgetButton {
@@ -437,7 +522,35 @@ Panel {
width: parent.width width: parent.width
spacing: Style.space(10) spacing: Style.space(10)
// Paired / known devices. // Connected devices.
PanelSectionHeader {
visible: root.connectedDevices.length > 0
text: "Connected"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Repeater {
model: root.connectedDevices
DeviceRow {
required property var modelData
required property int index
width: deviceList.width
dev: modelData
rowIndex: index
sectionName: "connected"
isDiscovered: false
}
}
// Remembered devices.
PanelSectionHeader {
visible: root.knownDevices.length > 0
text: "Paired"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
Repeater { Repeater {
model: root.knownDevices model: root.knownDevices
DeviceRow { DeviceRow {
@@ -446,6 +559,7 @@ Panel {
width: deviceList.width width: deviceList.width
dev: modelData dev: modelData
rowIndex: index rowIndex: index
sectionName: "known"
isDiscovered: false isDiscovered: false
} }
} }
@@ -453,7 +567,7 @@ Panel {
// Discovered (unpaired) devices, only shown while scanning. // Discovered (unpaired) devices, only shown while scanning.
PanelSectionHeader { PanelSectionHeader {
visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0 visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0
text: "Discovered" text: "Available"
foreground: root.bar.foreground foreground: root.bar.foreground
fontFamily: root.bar.fontFamily fontFamily: root.bar.fontFamily
} }
@@ -466,12 +580,14 @@ Panel {
width: deviceList.width width: deviceList.width
dev: modelData dev: modelData
rowIndex: index rowIndex: index
sectionName: "discovered"
isDiscovered: true isDiscovered: true
} }
} }
Text { Text {
visible: root.knownDevices.length === 0 visible: root.connectedDevices.length === 0
&& root.knownDevices.length === 0
&& (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0) && (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0)
text: !root.adapter ? "No Bluetooth adapter" text: !root.adapter ? "No Bluetooth adapter"
: !root.adapter.enabled ? "Turn Bluetooth on to scan" : !root.adapter.enabled ? "Turn Bluetooth on to scan"
@@ -518,20 +634,18 @@ Panel {
} }
} }
// Two-line device row showing name + live status (Connected, Connecting, // Two-line device row showing name + live status. Pending state is owned
// Pairing, Failed). Tracks pending click attempts with a Timer so a // by the panel so it survives rows moving between sections.
// connect that drops back to Disconnected within 10s surfaces as "Failed".
// Now a cursor target: hasCursor binds to root state, mouse hover updates
// root state. The X button on the right is a PanelActionButton.
component DeviceRow: CursorSurface { component DeviceRow: CursorSurface {
id: row id: row
required property var dev required property var dev
required property int rowIndex required property int rowIndex
required property string sectionName
required property bool isDiscovered required property bool isDiscovered
readonly property bool isConnected: dev && dev.connected readonly property bool isConnected: dev && dev.connected
readonly property int devState: dev && dev.state !== undefined ? dev.state : -1 readonly property int devState: dev && dev.state !== undefined ? dev.state : -1
readonly property string sectionName: isDiscovered ? "discovered" : "known" readonly property string action: root.pendingAction(dev ? dev.address : "")
hasCursor: root.cursorActive && root.focusSection === sectionName && root.selectedIndex === rowIndex hasCursor: root.cursorActive && root.focusSection === sectionName && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row) onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row)
@@ -540,61 +654,22 @@ Panel {
fill: root.hoverFill fill: root.hoverFill
currentFill: root.selectedFill currentFill: root.selectedFill
// 0 idle, 1 connecting, 2 disconnecting, 3 pairing, 4 failed.
property int pendingAction: 0
property string failureReason: ""
// Heuristic: while pendingAction is set, the connect/pair attempt is
// expected to land within ~10s. If state stays Disconnected past that, we
// declare failure. Cleared as soon as state reaches Connected.
Timer {
id: failureTimer
interval: 10000
repeat: false
onTriggered: {
if (row.pendingAction === 1 && !row.isConnected) {
row.pendingAction = 4
row.failureReason = "Could not connect"
} else if (row.pendingAction === 3 && row.dev && !row.dev.paired) {
row.pendingAction = 4
row.failureReason = "Pairing failed"
} else {
row.pendingAction = 0
row.failureReason = ""
}
}
}
Connections {
target: row.dev || null
function onConnectedChanged() {
if (row.isConnected) { row.pendingAction = 0; row.failureReason = "" }
}
function onPairedChanged() {
if (row.dev && row.dev.paired && row.pendingAction === 3) {
row.pendingAction = 0
}
}
}
readonly property string statusText: { readonly property string statusText: {
if (!dev) return "" if (!dev) return ""
if (pendingAction === 4) return failureReason || "Failed" if (action === "forgetting") return "Forgetting…"
if (pendingAction === 1 || devState === 3) return "Connecting…" if (action === "disconnecting" || devState === 2) return "Disconnecting…"
if (pendingAction === 2 || devState === 2) return "Disconnecting…"
if (pendingAction === 3 || (dev.pairing === true)) return "Pairing…"
if (isConnected) { if (isConnected) {
if (dev.batteryAvailable) return "Connected · " + Math.round(dev.battery * 100) + "%" if (dev.batteryAvailable) return Math.round(dev.battery * 100) + "%"
return "Connected" return sectionName === "connected" ? "" : "Connected"
} }
if (action === "connecting" || devState === 3 || dev.pairing === true) return "Connecting…"
if (isDiscovered) return "" if (isDiscovered) return ""
return "" return ""
} }
readonly property color statusColor: { readonly property color statusColor: {
if (pendingAction === 4) return root.bar.urgent
if (isConnected) return root.bar.foreground if (isConnected) return root.bar.foreground
if (pendingAction === 1 || devState === 3 || pendingAction === 3) return root.bar.foreground if (action !== "" || devState === 3 || dev.pairing === true) return root.bar.foreground
return Qt.darker(root.bar.foreground, 1.5) return Qt.darker(root.bar.foreground, 1.5)
} }
@@ -616,23 +691,12 @@ Panel {
onClicked: function(mouse) { onClicked: function(mouse) {
if (!row.dev) return if (!row.dev) return
if (mouse.button === Qt.RightButton) { if (mouse.button === Qt.RightButton) {
if (row.dev.forget) row.dev.forget() if (row.isConnected) root.disconnectDevice(row.dev)
else if (!row.isDiscovered) root.forgetDevice(row.dev)
return return
} }
if (row.isDiscovered) {
row.pendingAction = 3
row.failureReason = ""
failureTimer.restart()
root.pendingPairAddresses[row.dev.address] = true
row.dev.pair()
return
}
if (!row.dev.trusted) row.dev.trusted = true
if (row.isConnected) return // use the X button to disconnect if (row.isConnected) return // use the X button to disconnect
row.pendingAction = 1 root.connectDevice(row.dev)
row.failureReason = ""
failureTimer.restart()
row.dev.connect()
} }
} }
@@ -669,13 +733,8 @@ Panel {
fontFamily: root.bar.fontFamily fontFamily: root.bar.fontFamily
onClicked: { onClicked: {
if (!row.dev) return if (!row.dev) return
if (row.isConnected) { if (row.isConnected) root.disconnectDevice(row.dev)
row.pendingAction = 2 else root.forgetDevice(row.dev)
failureTimer.stop()
row.dev.disconnect()
} else if (row.dev.forget) {
row.dev.forget()
}
} }
} }