import QtQuick import Quickshell import Quickshell.Io import "." as M M.HoverPanel { id: menuWindow contentWidth: 250 panelNamespace: "nova-bluetooth" onVisibleChanged: if (visible) scanner.running = true readonly property bool _busy: powerProc.running || toggleProc.running property var _devices: [] property bool _btEnabled: true property Process _scanner: Process { id: scanner running: false command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"] stdout: StdioCollector { onStreamFinished: { const sections = text.split("---DEVICES---"); menuWindow._btEnabled = (sections[0] || "").trim() === "yes"; const devs = []; for (const line of (sections[1] || "").trim().split("\n")) { if (!line) continue; const i1 = line.indexOf(":"); const i2 = line.indexOf(":", i1 + 1); const i3 = line.indexOf(":", i2 + 1); if (i3 < 0) continue; devs.push({ "mac": line.slice(0, i1), "connected": line.slice(i1 + 1, i2) === "1", "battery": parseInt(line.slice(i2 + 1, i3)) || -1, "name": line.slice(i3 + 1) }); } devs.sort((a, b) => { if (a.connected !== b.connected) return a.connected ? -1 : 1; return a.name.localeCompare(b.name); }); menuWindow._devices = devs; } } } property Process _powerProc: Process { id: powerProc property string _action: "" command: ["bluetoothctl", "power", _action] onRunningChanged: if (!running) scanner.running = true } property Process _toggleProc: Process { id: toggleProc property string action: "" property string mac: "" command: ["bluetoothctl", action, mac] onRunningChanged: if (!running) scanner.running = true } // Bluetooth radio toggle header Item { width: menuWindow.contentWidth height: 36 Rectangle { anchors.fill: parent anchors.leftMargin: 4 anchors.rightMargin: 4 color: btHeaderHover.hovered ? M.Theme.base02 : "transparent" radius: M.Theme.radius } Text { id: btHeaderIcon anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "\uF294" color: menuWindow._btEnabled ? menuWindow.accentColor : M.Theme.base04 font.pixelSize: M.Theme.fontSize + 1 font.family: M.Theme.iconFontFamily } Text { anchors.left: btHeaderIcon.right anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter text: "Bluetooth" color: menuWindow._btEnabled ? M.Theme.base05 : M.Theme.base04 font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily font.bold: true } Text { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "\uF011" color: menuWindow._btEnabled ? menuWindow.accentColor : M.Theme.base04 font.pixelSize: M.Theme.fontSize font.family: M.Theme.iconFontFamily } HoverHandler { id: btHeaderHover cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: { powerProc._action = menuWindow._btEnabled ? "off" : "on"; powerProc.running = true; } } } Rectangle { width: menuWindow.contentWidth - 16 height: 1 anchors.horizontalCenter: parent.horizontalCenter color: M.Theme.base03 } Repeater { model: menuWindow._devices delegate: Item { id: entry required property var modelData required property int index width: menuWindow.contentWidth height: 32 Rectangle { anchors.fill: parent anchors.leftMargin: 4 anchors.rightMargin: 4 color: entryHover.hovered ? M.Theme.base02 : "transparent" radius: M.Theme.radius } Text { id: btIcon anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "\uF294" color: entry.modelData.connected ? menuWindow.accentColor : M.Theme.base04 font.pixelSize: M.Theme.fontSize + 1 font.family: M.Theme.iconFontFamily } Text { anchors.left: btIcon.right anchors.leftMargin: 8 anchors.right: batLabel.left anchors.rightMargin: 4 anchors.verticalCenter: parent.verticalCenter text: entry.modelData.name color: entry.modelData.connected ? menuWindow.accentColor : M.Theme.base05 font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily font.bold: entry.modelData.connected elide: Text.ElideRight } Text { id: batLabel anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter text: entry.modelData.battery >= 0 ? entry.modelData.battery + "%" : "" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily width: entry.modelData.battery >= 0 ? implicitWidth : 0 } HoverHandler { id: entryHover cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: { toggleProc.action = entry.modelData.connected ? "disconnect" : "connect"; toggleProc.mac = entry.modelData.mac; toggleProc.running = true; } } } } Text { visible: menuWindow._devices.length === 0 width: menuWindow.contentWidth height: 32 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: menuWindow._btEnabled ? "No paired devices" : "Bluetooth is off" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily } }