diff --git a/bin/omarchy b/bin/omarchy index 8f739bad..1f253ed3 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -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" diff --git a/bin/omarchy-bluetooth-device b/bin/omarchy-bluetooth-device new file mode 100755 index 00000000..d121b7c7 --- /dev/null +++ b/bin/omarchy-bluetooth-device @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Control a Bluetooth device +# omarchy:group=bluetooth +# omarchy:args=[pair|connect|disconnect|forget]
+# omarchy:examples=omarchy bluetooth device connect 00:11:22:33:44:55 + +set -e + +usage() { + echo "Usage: omarchy-bluetooth-device [pair|connect|disconnect|forget]
" >&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 diff --git a/shell/plugins/panels/Bluetooth.qml b/shell/plugins/panels/Bluetooth.qml index b5825e57..99d7d328 100644 --- a/shell/plugins/panels/Bluetooth.qml +++ b/shell/plugins/panels/Bluetooth.qml @@ -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) } }