From e5809efc6ade533df865f052f841955d3b390f1f Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:17:03 +0100 Subject: [PATCH] UI: Moved Updates to bluetooth devices inside bluetooth box --- .../modules/bar/BluetoothIndicator.qml | 115 +++++++++++++++++- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 575b561..1a6b830 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -79,6 +79,7 @@ Item { bonded: !!d.bonded, trusted: !!d.trusted, pairing: !!d.pairing, + state: d.state, batteryAvailable: !!d.batteryAvailable, battery: d.battery || 0 } @@ -105,19 +106,107 @@ Item { return null } + // --- Action feedback --------------------------------------------------- + // BlueZ operations here are fire-and-forget from QML's side — connect()/ + // disconnect()/pair()/forget() report failure only via a C++-side log + // line (see quickshell's bluetooth/device.cpp), nothing reaches QML. So + // "did it work" has to be inferred from the device's own properties + // instead of a success/failure callback. Shown as a line right under the + // specific row it's about (DeviceRow.statusText below) rather than a + // panel-wide banner — actionStatusAddress is how a row knows a message + // is meant for it; since it's address-keyed rather than tied to a + // particular DeviceRow instance, the message still finds its row even + // when the device jumps sections (Paired → Connected on a successful + // connect, say), because that's a new DeviceRow for the same address. + // - busyStatus is purely reactive — it just reads pairing/state off + // whichever row is currently mid-operation, however that operation + // was triggered. It's not shown directly (each row already derives + // the same "Connecting…"/"Disconnecting…" off its own dev.state); + // it only exists here to tell resolvePendingAction when to fire. + // - pendingAction+the busyStatus watcher below is how a *specific* + // click gets its own outcome message: it snapshots what was asked + // for, waits for busyStatus to clear, then checks whether the + // expected end state (connected/paired) actually landed. + readonly property string busyStatus: { + for (const r of root.deviceRows) { + if (r.pairing) return "pairing:" + r.address + if (r.state === BluetoothDeviceState.Connecting) return "connecting:" + r.address + if (r.state === BluetoothDeviceState.Disconnecting) return "disconnecting:" + r.address + } + return "" + } + + property var pendingAction: null + property string actionStatus: "" + property string actionStatusAddress: "" + + // Checks whether pendingAction's expected end state actually landed and + // reports it, then clears pendingAction. Called both when busyStatus + // clears normally and from pendingTimeoutTimer below — an operation + // that resolves inside a single binding re-evaluation (a device already + // in range reconnecting near-instantly, say) can flip busyStatus from "" + // straight back to "" without ever visibly changing, which would + // otherwise leave pendingAction stuck forever and misattribute whatever + // busyStatus transition happens to come along next. + function resolvePendingAction() { + if (!root.pendingAction) return + const p = root.pendingAction + root.pendingAction = null + const d = root.deviceForAddress(p.address) + root.actionStatusAddress = p.address + if (p.kind === "disconnect") { + root.actionStatus = (d && !d.connected) ? "Disconnected" : "Couldn't disconnect" + } else if (p.kind === "pair") { + root.actionStatus = (d && d.paired) ? "Paired" : "Couldn't pair" + } else { + root.actionStatus = (d && d.connected) ? "Connected" : "Couldn't connect" + } + actionStatusTimer.restart() + } + + onBusyStatusChanged: if (root.busyStatus === "") root.resolvePendingAction() + + Timer { + id: actionStatusTimer + interval: 3500 + repeat: false + onTriggered: root.actionStatus = "" + } + + // Backstop for the "resolved before busyStatus ever visibly changed" + // case above, and a genuine timeout for an operation that's just stuck + // (device out of range, etc.) — either way, restarted whenever + // pendingAction is (re)set, so it always reflects the latest click. + Timer { + id: pendingTimeoutTimer + interval: 8000 + repeat: false + onTriggered: root.resolvePendingAction() + } + function connectDevice(row) { const d = root.deviceForAddress(row.address) if (!d) return // Already-paired devices just reconnect; a fresh discovery result // has to pair first (BlueZ's own agent handles the SSP/passkey // handshake — there's no PIN-entry surface exposed by this API). - if (row.paired || row.bonded || row.trusted) d.connect() - else d.pair() + if (row.paired || row.bonded || row.trusted) { + root.pendingAction = { address: row.address, kind: "connect" } + pendingTimeoutTimer.restart() + d.connect() + } else { + root.pendingAction = { address: row.address, kind: "pair" } + pendingTimeoutTimer.restart() + d.pair() + } } function disconnectDevice(row) { const d = root.deviceForAddress(row.address) - if (d) d.disconnect() + if (!d) return + root.pendingAction = { address: row.address, kind: "disconnect" } + pendingTimeoutTimer.restart() + d.disconnect() } function forgetDevice(row) { @@ -125,11 +214,18 @@ Item { if (!d) return if (d.connected) d.disconnect() d.forget() + // No feedback line here — forgetting removes this row from every + // list immediately, so there's nothing left to show a message + // under. The row vanishing is the confirmation. } function setTrusted(row, trusted) { const d = root.deviceForAddress(row.address) - if (d) d.trusted = trusted + if (!d) return + d.trusted = trusted + root.actionStatusAddress = row.address + root.actionStatus = trusted ? "Trusting" : "No longer trusting" + actionStatusTimer.restart() } readonly property bool anyConnected: root.connectedRows.length > 0 @@ -301,6 +397,17 @@ Item { Text { readonly property string statusText: { if (devRow.dev.pairing) return "Pairing…" + if (devRow.dev.state === BluetoothDeviceState.Connecting) return "Connecting…" + if (devRow.dev.state === BluetoothDeviceState.Disconnecting) return "Disconnecting…" + // The outcome of the last action taken on this + // specific device (connect/disconnect/pair/trust) — + // see the "Action feedback" comment above + // root.busyStatus. Matched by address rather than by + // this being "the same row" that was clicked, since + // a successful connect/disconnect moves the device + // to a different section (a new DeviceRow entirely). + if (root.actionStatusAddress === devRow.dev.address && root.actionStatus !== "") + return root.actionStatus if (devRow.dev.connected && devRow.dev.batteryAvailable) return Math.round(devRow.dev.battery * 100) + "%" if (devRow.dev.connected) return "Connected"