import QtQuick import QtQuick.Layouts import Quickshell import Quickshell.Hyprland import Quickshell.Bluetooth import "../../config" Item { id: root // Independent left/right breathing room around the glyph. Material // Symbols icons aren't monospaced — each one's font-reported advance // width (label.implicitWidth) bakes in its own side bearings, so the // same "+4, centered" math that every other bar icon uses can still // look tighter/looser icon-to-icon. Tune these two by eye instead of // the icon's own bounding box to even it out against its neighbours. property real iconLeftPadding: 0 property real iconRightPadding: 0 implicitWidth: root.iconLeftPadding + label.implicitWidth + root.iconRightPadding 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 // 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.verticalCenter: parent.verticalCenter anchors.left: parent.left anchors.leftMargin: root.iconLeftPadding font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 16 color: (root.adapter && root.adapter.enabled) ? Colors.foreground : Colors.muted text: { if (!root.adapter || !root.adapter.enabled) return "bluetooth_disabled" return root.anyConnected ? "bluetooth_connected" : "bluetooth" } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor 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 } // Hover-only outline, offset outside the switch itself rather than // drawn on its edge — the switch lags behind a click until the // backend confirms it (e.g. the adapter's enabled state), so this // is just a "you can click this" cue, not click feedback. Rectangle { anchors.fill: parent anchors.margins: -4 radius: 0 color: "transparent" border.color: Colors.foreground border.width: toggleMouse.containsMouse ? 1 : 0 } 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 { id: toggleMouse anchors.fill: parent hoverEnabled: true 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.iconFontFamilyRounded 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.iconFontFamilyRounded 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.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 21 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) } } } } } } } } } }