From d6f79685f041e9dc4325c162b72ff1c8ae7e7ff9 Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:07:52 +0100 Subject: [PATCH 1/2] Feature: Extra functionablity to bluetooth actions --- .../modules/bar/BluetoothIndicator.qml | 226 ++++++++++++++---- 1 file changed, 174 insertions(+), 52 deletions(-) diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index bcfca55..575b561 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -127,6 +127,11 @@ Item { d.forget() } + function setTrusted(row, trusted) { + const d = root.deviceForAddress(row.address) + if (d) d.trusted = trusted + } + readonly property bool anyConnected: root.connectedRows.length > 0 // Caps how tall the Paired/Available list gets before it scrolls @@ -203,8 +208,13 @@ Item { } } - // One device row. `showForget` hides the forget (x) button for - // discovered-but-unpaired devices, which have nothing to forget yet. + // One device row. `showForget` gates the whole trailing action cluster — + // trust toggle, disconnect, forget — for discovered-but-unpaired devices, + // which have none of those to act on yet (a plain click there pairs + // instead, via activate()). For a paired/connected row, the row body + // itself still connects/disconnects (activate()) same as before; the + // actions below are the explicit, discoverable versions of that plus + // the two this row previously had no way to reach at all. component DeviceRow: Rectangle { id: devRow required property var dev @@ -213,6 +223,27 @@ Item { width: parent ? parent.width : 0 radius: 0 implicitHeight: rowContent.implicitHeight + 12 + + // Which action icon (if any) the pointer is currently over — drives + // both that icon's own hover color below and rowMouse's click + // dispatch. Derived from rowMouse.mouseX/containsMouse rather than a + // MouseArea on each icon: those icons live inside rowContent, which + // sits underneath rowMouse in stacking order, so a nested MouseArea + // there would just be shadowed by it (same reason + // NetworkIndicator.qml's NetworkRow dispatches by position too). + // Checked right-to-left since that's the order the icons are laid + // out in, so an earlier icon's "from here to the row's right edge" + // zone doesn't swallow the ones after it. + readonly property string hoveredAction: { + if (!rowMouse.containsMouse) return "" + if (devRow.showForget && forgetIcon.visible + && rowMouse.mouseX >= devRow.mapFromItem(forgetIcon, 0, 0).x - 8) return "forget" + if (devRow.showForget && trustIcon.visible + && rowMouse.mouseX >= devRow.mapFromItem(trustIcon, 0, 0).x - 8) return "trust" + if (disconnectIcon.visible + && rowMouse.mouseX >= devRow.mapFromItem(disconnectIcon, 0, 0).x - 8) return "disconnect" + return "" + } // Hover checked first (same ordering as Volume.qml's DeviceRow): // hovering always shows a solid Colors.primary fill, even for an // already-connected row, so the primaryText used below never has to @@ -283,14 +314,97 @@ Item { } } - Text { + // Each action below is a fixed-size slot with its own + // hover-halo outline — offset outside the slot rather than drawn + // on its own edge, same treatment as devRow's own outline above + // and every other hoverable box in this file (ToggleSwitch, + // DeviceRow itself, etc.) — instead of just the color swap the + // row's own icons get. + + // Disconnect without forgetting — same effect as clicking the + // row body on a Connected row (activate() below calls + // disconnectDevice too), just given its own explicit, always- + // visible icon instead of relying on that being guessed. + Item { + id: disconnectIcon + visible: devRow.showForget && devRow.dev.connected + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: devRow.hoveredAction === "disconnect" ? 1 : 0 + } + Text { + anchors.centerIn: parent + text: "link_off" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + // Stays primaryText (dark) rather than switching to + // Colors.primary while targeted — devRow's own fill is + // already Colors.primary on hover, so that would've put + // a primary-colored glyph on a primary-colored + // background. The outline Rectangle above is what shows + // which slot is targeted. + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + // Trust toggle — full brightness when trusted, dimmed when not, + // same "state via opacity" convention PowerButton.qml uses for + // its disabled entries. + Item { + id: trustIcon + visible: devRow.showForget + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: devRow.hoveredAction === "trust" ? 1 : 0 + } + Text { + anchors.centerIn: parent + text: "verified_user" + opacity: devRow.dev.trusted ? 1 : 0.4 + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + Item { id: forgetIcon visible: devRow.showForget - text: "close" - font.family: Appearance.iconFontFamilyRounded - font.variableAxes: Appearance.iconFontAxes - font.pixelSize: 14 - color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + + Rectangle { + anchors.fill: parent + anchors.margins: -3 + radius: 0 + color: "transparent" + border.color: Colors.foreground + border.width: devRow.hoveredAction === "forget" ? 1 : 0 + } + Text { + anchors.centerIn: parent + text: "close" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } } } @@ -302,21 +416,15 @@ Item { height: rowContent.height + 12 hoverEnabled: true cursorShape: Qt.PointingHandCursor - // forgetIcon has no MouseArea of its own — nested inside - // rowContent, which sits underneath this full-row area in - // stacking order, so a separate MouseArea there would just be - // shadowed (see the identical fix in NetworkIndicator.qml's - // NetworkRow). Dispatch by click position against this single - // area instead. - onClicked: (mouse) => { - if (devRow.showForget && forgetIcon.visible) { - const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x - if (mouse.x >= forgetLeft - 8) { - root.forgetDevice(devRow.dev) - return - } + // Dispatches on devRow.hoveredAction rather than its own + // MouseArea per icon — see that property's comment above. + onClicked: { + switch (devRow.hoveredAction) { + case "forget": root.forgetDevice(devRow.dev); break + case "trust": root.setTrusted(devRow.dev, !devRow.dev.trusted); break + case "disconnect": root.disconnectDevice(devRow.dev); break + default: devRow.activate() } - devRow.activate() } } } @@ -327,7 +435,11 @@ Item { id: panel visible: false color: "transparent" - readonly property int contentWidth: 244 + // Wider than the other panels' usual 244 — each device row now ends + // in a row of three 20px action slots (disconnect/trust/forget) + // instead of a single close icon, and the name column needs room to + // not immediately start eliding against that. + readonly property int contentWidth: 300 readonly property int contentPadding: 16 implicitWidth: contentWidth + contentPadding * 2 implicitHeight: mainColumn.implicitHeight + contentPadding * 2 @@ -470,6 +582,42 @@ Item { } } + // Paired — always shown in full, same as Connected above and + // for the same reason: these are one click away from + // reconnecting (root.connectDevice reconnects rather than + // re-pairing whenever a row is already paired/bonded/trusted), + // so they shouldn't be buried in the capped-height scroll area + // below or hidden behind an active scan turning up other + // nearby devices. + Column { + width: parent.width + spacing: 4 + visible: root.knownRows.length > 0 + + Text { + text: "Paired Devices" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + // Inset 3px on each side to match deviceListColumn + // below, so DeviceRow's hover halo has room here too. + x: 3 + width: parent.width - 6 + spacing: 2 + Repeater { + model: root.knownRows + DeviceRow { + required property var modelData + dev: modelData + showForget: true + onActivate: root.connectDevice(dev) + } + } + } + } + Text { width: parent.width visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0 @@ -479,9 +627,9 @@ Item { color: Colors.muted } - // Paired/Available — capped height, scrolls once there are - // more devices than fit rather than growing the popup - // indefinitely. + // Available — freshly discovered, unpaired devices only. + // Capped height, scrolls once there are more than fit rather + // than growing the popup indefinitely. Flickable { id: deviceListFlick width: parent.width @@ -507,32 +655,6 @@ Item { bottomPadding: 3 spacing: 6 - Column { - width: parent.width - spacing: 4 - visible: root.knownRows.length > 0 - - Text { - text: "Paired Devices" - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.muted - } - Column { - width: parent.width - spacing: 2 - Repeater { - model: root.knownRows - DeviceRow { - required property var modelData - dev: modelData - showForget: true - onActivate: root.connectDevice(dev) - } - } - } - } - Column { width: parent.width spacing: 4 -- 2.49.1 From e5809efc6ade533df865f052f841955d3b390f1f Mon Sep 17 00:00:00 2001 From: thomas Date: Thu, 27 Aug 2026 14:17:03 +0100 Subject: [PATCH 2/2] 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" -- 2.49.1