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[audio]="Audio input and output controls"
GROUP_DESCRIPTIONS[battery]="Battery status helpers"
GROUP_DESCRIPTIONS[bluetooth]="Bluetooth device controls"
GROUP_DESCRIPTIONS[branch]="Omarchy git branch management"
GROUP_DESCRIPTIONS[branding]="About and screensaver branding"
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"
ipcTarget: "panels.bluetooth"
// Address -> true while we are waiting for a click-initiated pair to land
// so we can chain trust + connect at root scope. Doing this in the row's
// Connections is racy: the discovered Repeater destroys the delegate the
// moment `paired` flips, before the row's handler reliably fires.
property var pendingPairAddresses: ({})
// 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 : []
@@ -46,8 +45,13 @@ Panel {
readonly property var connectedDevices: {
var list = []
for (var i = 0; i < devices.length; i++)
if (devices[i] && devices[i].connected && hasHumanName(devices[i])) list.push(devices[i])
for (var i = 0; i < devices.length; 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
}
@@ -55,10 +59,9 @@ Panel {
var list = []
for (var i = 0; i < devices.length; 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) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
return deviceLabel(a).localeCompare(deviceLabel(b))
})
return list
@@ -69,7 +72,7 @@ Panel {
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
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.sort(function(a, b) {
@@ -88,8 +91,9 @@ Panel {
// Single cursor model shared by keyboard and mouse. Sections:
// "header" — 2 action pills (scan, toggle); h/l moves between
// them, Enter activates.
// "known" — paired/known device rows; Enter toggles connect.
// "discovered" — unpaired devices visible while scanning; Enter pairs.
// "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.
@@ -98,11 +102,10 @@ Panel {
property bool cursorActive: false
readonly property int headerPillCount: 2
// Stable identity for the focused known device. The known list is sorted
// (connected-first, then alphabetical) so activating a device can shift
// its index. We track the BlueZ address here so the cursor follows the
// same device across reorders rather than the slot it used to occupy.
property string focusedKnownAddress: ""
// 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: ""
readonly property color hoverFill: bar
? Style.hoverFillFor(bar.foreground, Color.accent)
@@ -113,6 +116,7 @@ Panel {
function sectionCount(section) {
if (section === "header") return headerPillCount
if (section === "connected") return connectedDevices.length
if (section === "known") return knownDevices.length
if (section === "discovered") return discoveredDevices.length
return 0
@@ -120,6 +124,7 @@ Panel {
function sectionVisible(section) {
if (section === "header") return true
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
@@ -127,11 +132,102 @@ Panel {
readonly property var visibleSections: {
var list = ["header"]
if (sectionVisible("connected")) list.push("connected")
if (sectionVisible("known")) list.push("known")
if (sectionVisible("discovered")) list.push("discovered")
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
// 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
@@ -185,19 +281,17 @@ Panel {
}
return
}
if (focusSection === "known") {
var dev = knownDevices[selectedIndex]
if (focusSection === "connected" || focusSection === "known") {
var dev = deviceAt(focusSection, selectedIndex)
if (!dev) return
if (!dev.trusted) dev.trusted = true
if (dev.connected) dev.disconnect()
else dev.connect()
if (dev.connected) disconnectDevice(dev)
else connectDevice(dev)
return
}
if (focusSection === "discovered") {
var d = discoveredDevices[selectedIndex]
if (!d) return
pendingPairAddresses[d.address] = true
d.pair()
connectDevice(d)
}
}
@@ -207,52 +301,61 @@ Panel {
// tooltip says "Disconnect" for connected rows, and the keybind has
// to agree.
function deleteSelected() {
if (focusSection !== "known") return
var dev = knownDevices[selectedIndex]
if (focusSection !== "connected" && focusSection !== "known") return
var dev = deviceAt(focusSection, selectedIndex)
if (!dev) return
if (dev.connected) dev.disconnect()
else if (dev.forget) dev.forget()
if (dev.connected) disconnectDevice(dev)
else forgetDevice(dev)
}
onOpenedChanged: {
if (opened) {
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 }
cursorActive = false
}
}
// When `selectedIndex` changes inside the known section, remember which
// address it points at. Updates from re-resolution (below) are idempotent
// because we end up setting the same address.
onSelectedIndexChanged: {
if (focusSection !== "known") return
if (selectedIndex < 0 || selectedIndex >= knownDevices.length) return
var d = knownDevices[selectedIndex]
focusedKnownAddress = d ? (d.address || "") : ""
function updateFocusedAddress() {
if (focusSection === "header") {
focusedDeviceAddress = ""
return
}
var d = deviceAt(focusSection, selectedIndex)
focusedDeviceAddress = d ? (d.address || "") : ""
}
onFocusSectionChanged: {
if (focusSection !== "known") focusedKnownAddress = ""
}
function reselectFocusedDevice() {
if (focusedDeviceAddress === "") {
clampCursor()
return
}
onKnownDevicesChanged: {
// Try to follow the device by address before clamping. If we can't find
// the address (e.g. it was forgotten), fall through to clampCursor()
// which will pull selectedIndex back into range.
if (focusSection === "known" && focusedKnownAddress !== "") {
for (var i = 0; i < knownDevices.length; i++) {
if (knownDevices[i] && knownDevices[i].address === focusedKnownAddress) {
if (selectedIndex !== i) selectedIndex = i
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()
}
onDiscoveredDevicesChanged: 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
@@ -303,29 +406,11 @@ Panel {
}
}
// Non-visual lifecycle watchers, one per device. Survives popup open/close
// and the discovered-known transition that destroys row delegates.
Repeater {
model: root.devices
Item {
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()
}
}
}
Timer {
id: pendingTimeout
interval: 20000
repeat: false
onTriggered: root.pendingActions = ({})
}
WidgetButton {
@@ -437,7 +522,35 @@ Panel {
width: parent.width
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 {
model: root.knownDevices
DeviceRow {
@@ -446,6 +559,7 @@ Panel {
width: deviceList.width
dev: modelData
rowIndex: index
sectionName: "known"
isDiscovered: false
}
}
@@ -453,7 +567,7 @@ Panel {
// Discovered (unpaired) devices, only shown while scanning.
PanelSectionHeader {
visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0
text: "Discovered"
text: "Available"
foreground: root.bar.foreground
fontFamily: root.bar.fontFamily
}
@@ -466,12 +580,14 @@ Panel {
width: deviceList.width
dev: modelData
rowIndex: index
sectionName: "discovered"
isDiscovered: true
}
}
Text {
visible: root.knownDevices.length === 0
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"
@@ -518,20 +634,18 @@ Panel {
}
}
// Two-line device row showing name + live status (Connected, Connecting,
// Pairing, Failed). Tracks pending click attempts with a Timer so a
// 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.
// 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 sectionName: isDiscovered ? "discovered" : "known"
readonly property string action: root.pendingAction(dev ? dev.address : "")
hasCursor: root.cursorActive && root.focusSection === sectionName && root.selectedIndex === rowIndex
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(row)
@@ -540,61 +654,22 @@ Panel {
fill: root.hoverFill
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: {
if (!dev) return ""
if (pendingAction === 4) return failureReason || "Failed"
if (pendingAction === 1 || devState === 3) return "Connecting…"
if (pendingAction === 2 || devState === 2) return "Disconnecting…"
if (pendingAction === 3 || (dev.pairing === true)) return "Pairing…"
if (action === "forgetting") return "Forgetting…"
if (action === "disconnecting" || devState === 2) return "Disconnecting…"
if (isConnected) {
if (dev.batteryAvailable) return "Connected · " + Math.round(dev.battery * 100) + "%"
return "Connected"
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 (pendingAction === 4) return root.bar.urgent
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)
}
@@ -616,23 +691,12 @@ Panel {
onClicked: function(mouse) {
if (!row.dev) return
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
}
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
row.pendingAction = 1
row.failureReason = ""
failureTimer.restart()
row.dev.connect()
root.connectDevice(row.dev)
}
}
@@ -669,13 +733,8 @@ Panel {
fontFamily: root.bar.fontFamily
onClicked: {
if (!row.dev) return
if (row.isConnected) {
row.pendingAction = 2
failureTimer.stop()
row.dev.disconnect()
} else if (row.dev.forget) {
row.dev.forget()
}
if (row.isConnected) root.disconnectDevice(row.dev)
else root.forgetDevice(row.dev)
}
}