diff --git a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml index 8087ee1..09bef2b 100644 --- a/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml @@ -1,4 +1,7 @@ import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Hyprland import Quickshell.Bluetooth import "../../config" @@ -7,16 +10,122 @@ Item { implicitWidth: label.implicitWidth + 4 implicitHeight: Appearance.barHeight + // The bar's own PanelWindow — passed in from Bar.qml so this popup can + // anchor to the bar itself rather than to this icon, keeping it a fixed + // distance from the bar and the screen edge regardless of where this + // icon sits in the row. + required property var barWindow + readonly property var adapter: Bluetooth.defaultAdapter - readonly property bool anyConnected: { - if (!adapter) return false - const devices = adapter.devices.values - for (let i = 0; i < devices.length; i++) { - if (devices[i].connected) return true - } - return false + + // Scan only while the panel is open — same convention as the WiFi + // scannerEnabled binding in NetworkIndicator.qml. + Binding { + target: root.adapter + property: "discovering" + value: panel.visible && root.adapter.enabled + when: root.adapter !== null } + // --- Device row helpers ---------------------------------------------- + // Rows are plain objects rather than the live BluetoothDevice QObject — + // BlueZ churn (pairing timeouts, a device dropping out of range mid-scan) + // can destroy the object while a delegate still holds it, the same + // crash risk noted for WiFi networks in NetworkIndicator.qml. The actual + // object is re-resolved by address only when an action needs it, via + // deviceForAddress(). + function deviceLabel(d) { + return (d.deviceName || d.name || d.address || "").trim() + } + + // BlueZ's Icon property is a freedesktop icon-theme name (verified + // against a real paired device on this machine: "audio-headset") — map + // the common categories to a Material Symbols glyph, falling back to a + // generic "devices_other" for anything unrecognized. + function deviceIcon(icon) { + const i = icon || "" + if (i.indexOf("headset") !== -1 || i.indexOf("headphone") !== -1) return "headphones" + if (i.indexOf("mouse") !== -1) return "mouse" + if (i.indexOf("keyboard") !== -1) return "keyboard" + if (i.indexOf("gaming") !== -1 || i.indexOf("joystick") !== -1) return "sports_esports" + if (i.indexOf("tablet") !== -1) return "tablet" + if (i.indexOf("camera") !== -1) return "videocam" + if (i.indexOf("printer") !== -1) return "print" + if (i.indexOf("watch") !== -1) return "watch" + if (i.indexOf("tv") !== -1) return "tv" + if (i.indexOf("computer") !== -1) return "computer" + if (i.indexOf("card") !== -1) return "speaker" + return "devices_other" + } + + function deviceRow(d) { + if (!d) return null + const label = root.deviceLabel(d) + if (!label) return null + return { + address: d.address || "", + label: label, + icon: root.deviceIcon(d.icon), + connected: !!d.connected, + paired: !!d.paired, + bonded: !!d.bonded, + trusted: !!d.trusted, + pairing: !!d.pairing, + batteryAvailable: !!d.batteryAvailable, + battery: d.battery || 0 + } + } + + readonly property var deviceRows: root.adapter + ? root.adapter.devices.values.map(root.deviceRow).filter(function(r) { return r !== null }) + : [] + + readonly property var connectedRows: root.deviceRows.filter(function(r) { return r.connected }) + readonly property var knownRows: root.deviceRows.filter(function(r) { + return !r.connected && (r.paired || r.bonded || r.trusted) + }) + readonly property var discoveredRows: root.deviceRows.filter(function(r) { + return !r.connected && !r.paired && !r.bonded && !r.trusted + }) + + function deviceForAddress(address) { + if (!root.adapter) return null + const devs = root.adapter.devices.values + for (let i = 0; i < devs.length; i++) { + if (devs[i].address === address) return devs[i] + } + return null + } + + 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() + } + + function disconnectDevice(row) { + const d = root.deviceForAddress(row.address) + if (d) d.disconnect() + } + + function forgetDevice(row) { + const d = root.deviceForAddress(row.address) + if (!d) return + if (d.connected) d.disconnect() + d.forget() + } + + readonly property bool anyConnected: root.connectedRows.length > 0 + + // Caps how tall the Paired/Available list gets before it scrolls + // instead of continuing to grow the popup — same convention as + // NetworkIndicator.qml's Known/Other Networks list. + readonly property int maxDeviceListHeight: 260 + Text { id: label anchors.centerIn: parent @@ -33,6 +142,348 @@ Item { MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor - onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled + onClicked: { + panel.visible = !panel.visible + grab.active = panel.visible + } + } + + // --- Reusable pieces --------------------------------------------------- + + component ToggleSwitch: Item { + id: toggleRoot + property bool checked: false + signal toggled() + implicitWidth: 34 + implicitHeight: 18 + + Rectangle { + anchors.fill: parent + radius: 0 + color: toggleRoot.checked ? Colors.primary : Colors.muted + opacity: toggleRoot.checked ? 1 : 0.35 + } + Rectangle { + width: parent.height - 4 + height: parent.height - 4 + radius: 0 + color: Colors.background + anchors.verticalCenter: parent.verticalCenter + x: toggleRoot.checked ? parent.width - width - 2 : 2 + Behavior on x { NumberAnimation { duration: Appearance.animationDuration } } + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: toggleRoot.toggled() + } + } + + // One device row. `showForget` hides the forget (x) button for + // discovered-but-unpaired devices, which have nothing to forget yet. + component DeviceRow: Rectangle { + id: devRow + required property var dev + property bool showForget: true + signal activate() + width: parent ? parent.width : 0 + radius: 0 + implicitHeight: rowContent.implicitHeight + 12 + color: devRow.dev.connected + ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) + : (rowMouse.containsMouse ? Colors.primary : "transparent") + + RowLayout { + id: rowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: 6 + spacing: 8 + + Text { + Layout.preferredWidth: 20 + Layout.minimumWidth: 20 + Layout.maximumWidth: 20 + horizontalAlignment: Text.AlignLeft + text: devRow.dev.icon + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + Layout.fillWidth: true + text: devRow.dev.label + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: devRow.dev.connected + color: rowMouse.containsMouse ? Colors.primaryText : Colors.foreground + } + Text { + readonly property string statusText: { + if (devRow.dev.pairing) return "Pairing…" + if (devRow.dev.connected && devRow.dev.batteryAvailable) + return Math.round(devRow.dev.battery * 100) + "%" + if (devRow.dev.connected) return "Connected" + return "" + } + visible: statusText !== "" + text: statusText + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + Text { + id: forgetIcon + visible: devRow.showForget + text: "close" + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 14 + color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted + } + } + + MouseArea { + id: rowMouse + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + 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 + } + } + devRow.activate() + } + } + } + + // --- Panel ------------------------------------------------------------- + + PopupWindow { + id: panel + visible: false + color: "transparent" + readonly property int contentWidth: 244 + readonly property int contentPadding: 16 + implicitWidth: contentWidth + contentPadding * 2 + implicitHeight: mainColumn.implicitHeight + contentPadding * 2 + // Connected/paired/discovered devices come and go while the panel + // is open — animate the resize instead of snapping, same as the + // network panel's WiFi list. + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + anchor { + window: root.barWindow + rect.x: 0 + rect.y: 0 + rect.width: root.barWindow.width + rect.height: root.barWindow.height + edges: Edges.Bottom | Edges.Right + gravity: Edges.Bottom | Edges.Left + margins.bottom: -4 + margins.right: 0 + } + + HyprlandFocusGrab { + id: grab + windows: [panel] + onCleared: panel.visible = false + } + + Rectangle { + anchors.fill: parent + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 3 + + Column { + id: mainColumn + anchors.fill: parent + anchors.margins: panel.contentPadding + spacing: 10 + + // Header + RowLayout { + width: parent.width + spacing: 8 + + Text { + text: label.text + font.family: Appearance.iconFontFamily + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 30 + color: Colors.foreground + } + + Text { + Layout.fillWidth: true + text: "Bluetooth" + font.family: Appearance.fontFamily + font.pixelSize: 18 + font.bold: true + color: Colors.foreground + } + + ToggleSwitch { + visible: root.adapter !== null + checked: root.adapter !== null && root.adapter.enabled + onToggled: root.adapter.enabled = !root.adapter.enabled + } + } + + Text { + width: parent.width + visible: root.adapter === null + text: "No Bluetooth adapter" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + width: parent.width + visible: root.adapter !== null && !root.adapter.enabled + text: "Turn Bluetooth on to see devices" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // Connected — always shown in full above the scrollable + // list, mirroring the network panel's layout. + Column { + width: parent.width + spacing: 4 + visible: root.connectedRows.length > 0 + + Text { + text: "Connected" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.connectedRows + DeviceRow { + required property var modelData + dev: modelData + showForget: true + onActivate: root.disconnectDevice(dev) + } + } + } + } + + Text { + width: parent.width + visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0 + text: (root.adapter && root.adapter.discovering) ? "Scanning for devices…" : "No devices found" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + // Paired/Available — capped height, scrolls once there are + // more devices than fit rather than growing the popup + // indefinitely. + Flickable { + id: deviceListFlick + width: parent.width + height: Math.min(deviceListColumn.implicitHeight, root.maxDeviceListHeight) + contentWidth: width + contentHeight: deviceListColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + visible: deviceListColumn.implicitHeight > 0 + + Column { + id: deviceListColumn + width: deviceListFlick.width + 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 + visible: root.adapter !== null && root.adapter.discovering && root.discoveredRows.length > 0 + + Text { + text: "Available Devices" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + Column { + width: parent.width + spacing: 2 + Repeater { + model: root.discoveredRows + DeviceRow { + required property var modelData + dev: modelData + showForget: false + onActivate: root.connectDevice(dev) + } + } + } + } + } + } + } + } } }