Files
arthur-os/default/quickshell/omarchy-shell/plugins/bar/widgets/bluetoothPanel.qml
T

480 lines
16 KiB
QML

import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Bluetooth
import "../common" as Common
Item {
id: root
property QtObject bar: null
property string moduleName: "bluetoothPanel"
property var settings: ({})
property bool popupOpen: false
// Address -> true while we are waiting for a click-initiated pair to land
// so we can chain trust + connect at root scope. Doing this in the row's
// Connections is racy: the discovered Repeater destroys the delegate the
// moment `paired` flips, before the row's handler reliably fires.
property var pendingPairAddresses: ({})
function closePopout() { popupOpen = false }
readonly property var adapter: Bluetooth.defaultAdapter
readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : []
readonly property var connectedDevices: {
var list = []
for (var i = 0; i < devices.length; i++)
if (devices[i] && devices[i].connected) list.push(devices[i])
return list
}
readonly property var knownDevices: {
var list = []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
if (d && (d.paired || d.connected || d.bonded || d.trusted)) list.push(d)
}
list.sort(function(a, b) {
if (a.connected !== b.connected) return a.connected ? -1 : 1
return (a.name || a.deviceName || "").localeCompare(b.name || b.deviceName || "")
})
return list
}
readonly property var discoveredDevices: {
var list = []
for (var i = 0; i < devices.length; i++) {
var d = devices[i]
if (!d) continue
if (d.paired || d.connected || d.bonded || d.trusted) continue
list.push(d)
}
list.sort(function(a, b) {
return (a.name || a.deviceName || a.address || "").localeCompare(b.name || b.deviceName || b.address || "")
})
return list
}
readonly property string icon: {
if (!adapter) return ""
if (!adapter.enabled) return "󰂲"
if (connectedDevices.length > 0) return "󰂱"
return "󰂯"
}
visible: adapter !== null
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
// Non-visual lifecycle watchers, one per device. Survives popup open/close
// and the discovered↔known transition that destroys row delegates.
Repeater {
model: root.devices
Item {
required property var modelData
visible: false
Connections {
target: modelData || null
function onPairedChanged() {
var d = modelData
if (!d || !d.paired) return
if (!root.pendingPairAddresses[d.address]) return
delete root.pendingPairAddresses[d.address]
// BlueZ pair() does not auto-trust or auto-connect. Without
// trusted, the daemon may drop the entry shortly after pairing,
// which makes a freshly-paired device flash "Connected" and then
// vanish from the model.
d.trusted = true
if (!d.connected) d.connect()
}
}
}
}
Common.WidgetButton {
id: button
anchors.fill: parent
bar: root.bar
text: root.icon
tooltipText: root.adapter ? (root.adapter.enabled ? "Bluetooth: " + root.connectedDevices.length + " connected" : "Bluetooth off") : ""
onPressed: function(b) {
if (b === Qt.RightButton && root.adapter) root.adapter.enabled = !root.adapter.enabled
else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth")
else root.popupOpen = !root.popupOpen
}
}
Common.PopupCard {
anchorItem: button
owner: root
bar: root.bar
open: root.popupOpen
contentWidth: 320
contentHeight: column.implicitHeight + 28
Column {
id: column
anchors.fill: parent
spacing: 10
// Header: title left, on/off toggle right.
Item {
width: parent.width
height: titleText.implicitHeight
Text {
id: titleText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Bluetooth"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 13
font.bold: true
}
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
Common.PillButton {
iconText: "󰂳"
tooltipText: !root.adapter ? "" : !root.adapter.enabled ? "Bluetooth is off"
: root.adapter.discovering ? "Stop scanning" : "Scan for devices"
tooltipBackground: root.bar.background
tooltipForeground: root.bar.foreground
foreground: root.bar.foreground
horizontalPadding: 6
verticalPadding: 4
iconSize: 14
enabled: root.adapter !== null && root.adapter.enabled
opacity: enabled ? 1 : 0.4
active: root.adapter && root.adapter.discovering
onClicked: if (root.adapter) root.adapter.discovering = !root.adapter.discovering
}
Common.PillButton {
iconText: "󱁤"
tooltipText: "Open Impala (TUI)"
tooltipBackground: root.bar.background
tooltipForeground: root.bar.foreground
foreground: root.bar.foreground
horizontalPadding: 6
verticalPadding: 4
iconSize: 14
onClicked: { root.bar.run("omarchy-launch-bluetooth"); root.popupOpen = false }
}
Common.PillButton {
iconText: root.adapter && root.adapter.enabled ? "󰂯" : "󰂲"
text: root.adapter && root.adapter.enabled ? "On" : "Off"
tooltipText: root.adapter && root.adapter.enabled ? "Turn Bluetooth off" : "Turn Bluetooth on"
tooltipBackground: root.bar.background
tooltipForeground: root.bar.foreground
foreground: root.bar.foreground
horizontalPadding: 10
verticalPadding: 4
active: root.adapter && root.adapter.enabled
onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled
}
}
}
// Scrollable device list — capped so a noisy neighborhood doesn't
// grow the popup past the screen.
Flickable {
width: parent.width
height: Math.min(deviceList.implicitHeight, 400)
contentWidth: width
contentHeight: deviceList.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
Column {
id: deviceList
width: parent.width
spacing: 10
// Paired / known devices.
Repeater {
model: root.knownDevices
DeviceRow {
required property var modelData
width: deviceList.width
dev: modelData
isDiscovered: false
}
}
// Discovered (unpaired) devices, only shown while scanning.
Text {
visible: root.adapter && root.adapter.discovering && root.discoveredDevices.length > 0
text: "Discovered"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 10
font.bold: true
}
Repeater {
model: root.adapter && root.adapter.discovering ? root.discoveredDevices : []
DeviceRow {
required property var modelData
width: deviceList.width
dev: modelData
isDiscovered: true
}
}
Text {
visible: root.knownDevices.length === 0
&& (!root.adapter || !root.adapter.discovering || root.discoveredDevices.length === 0)
text: !root.adapter ? "No Bluetooth adapter"
: !root.adapter.enabled ? "Turn Bluetooth on to scan"
: root.adapter.discovering ? "Scanning for devices…"
: "No paired devices. Tap the scan icon to find new ones."
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
wrapMode: Text.WordWrap
width: deviceList.width
}
}
}
}
}
// Two-line device row showing name + live status (Connected, Connecting…,
// Pairing…, Failed). Tracks pending click attempts with a Timer so a
// connect that drops back to Disconnected within 10s surfaces as "Failed".
component DeviceRow: Rectangle {
id: row
required property var dev
required property bool isDiscovered
readonly property bool isConnected: dev && dev.connected
readonly property int devState: dev && dev.state !== undefined ? dev.state : -1
// 0 idle, 1 connecting, 2 disconnecting, 3 pairing, 4 failed.
property int pendingAction: 0
property string failureReason: ""
// Heuristic: while pendingAction is set, the connect/pair attempt is
// expected to land within ~10s. If state stays Disconnected past that, we
// declare failure. Cleared as soon as state reaches Connected.
Timer {
id: failureTimer
interval: 10000
repeat: false
onTriggered: {
if (row.pendingAction === 1 && !row.isConnected) {
row.pendingAction = 4
row.failureReason = "Could not connect"
} else if (row.pendingAction === 3 && row.dev && !row.dev.paired) {
row.pendingAction = 4
row.failureReason = "Pairing failed"
} else {
row.pendingAction = 0
row.failureReason = ""
}
}
}
Connections {
target: row.dev || null
function onConnectedChanged() {
if (row.isConnected) { row.pendingAction = 0; row.failureReason = "" }
}
function onPairedChanged() {
if (row.dev && row.dev.paired && row.pendingAction === 3) {
row.pendingAction = 0
}
}
}
readonly property string statusText: {
if (!dev) return ""
if (pendingAction === 4) return failureReason || "Failed"
if (pendingAction === 1 || devState === 3) return "Connecting\u2026"
if (pendingAction === 2 || devState === 2) return "Disconnecting\u2026"
if (pendingAction === 3 || (dev.pairing === true)) return "Pairing\u2026"
if (isConnected) {
if (dev.batteryAvailable) return "Connected · " + Math.round(dev.battery * 100) + "%"
return "Connected"
}
if (isDiscovered) return "Available · click to pair"
return "Paired"
}
readonly property color statusColor: {
if (pendingAction === 4) return root.bar.urgent
if (isConnected) return root.bar.foreground
if (pendingAction === 1 || devState === 3 || pendingAction === 3) return root.bar.foreground
return Qt.darker(root.bar.foreground, 1.5)
}
implicitHeight: rowContent.implicitHeight + 12
radius: 4
color: rowMouse.containsMouse
? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.10)
: (isConnected ? Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.05) : "transparent")
Behavior on color { ColorAnimation { duration: 120 } }
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: row.dev ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: function(mouse) {
if (!row.dev) return
if (mouse.button === Qt.RightButton) {
if (row.dev.forget) row.dev.forget()
return
}
if (row.isDiscovered) {
row.pendingAction = 3
row.failureReason = ""
failureTimer.restart()
root.pendingPairAddresses[row.dev.address] = true
row.dev.pair()
return
}
if (!row.dev.trusted) row.dev.trusted = true
if (row.isConnected) return // use the X button to disconnect
row.pendingAction = 1
row.failureReason = ""
failureTimer.restart()
row.dev.connect()
}
}
Item {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
implicitHeight: Math.max(icon.implicitHeight, info.implicitHeight, disconnectBtn.implicitHeight)
Text {
id: icon
text: row.isConnected ? "󰂱" : "󰂯"
color: row.statusColor
font.family: root.bar.fontFamily
font.pixelSize: 16
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
}
// Explicit close button on any known device. Action depends on state:
// connected -> disconnect, otherwise -> forget the pairing entirely.
Rectangle {
id: disconnectBtn
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 22
height: 22
radius: 4
visible: !row.isDiscovered
readonly property string action: row.isConnected ? "Disconnect" : "Forget"
color: disconnectMouse.containsMouse
? Qt.rgba(root.bar.urgent.r, root.bar.urgent.g, root.bar.urgent.b, 0.20)
: "transparent"
Behavior on color { ColorAnimation { duration: 120 } }
Text {
anchors.centerIn: parent
text: "󰅙"
color: disconnectMouse.containsMouse ? root.bar.urgent : Qt.darker(root.bar.foreground, 1.3)
font.family: root.bar.fontFamily
font.pixelSize: 14
}
MouseArea {
id: disconnectMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onClicked: {
if (!row.dev) return
if (row.isConnected) {
row.pendingAction = 2
failureTimer.stop()
row.dev.disconnect()
} else if (row.dev.forget) {
row.dev.forget()
}
}
}
ToolTip {
visible: disconnectMouse.containsMouse
text: disconnectBtn.action
delay: 400
padding: 0
background: Rectangle {
color: root.bar.background
border.color: root.bar.foreground
border.width: 1
radius: 0
opacity: 0.97
}
contentItem: Text {
text: disconnectBtn.action
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
leftPadding: 10
rightPadding: 10
topPadding: 6
bottomPadding: 6
}
}
}
Column {
id: info
spacing: 1
anchors.left: icon.right
anchors.leftMargin: 10
anchors.right: disconnectBtn.visible ? disconnectBtn.left : parent.right
anchors.rightMargin: disconnectBtn.visible ? 8 : 0
anchors.verticalCenter: parent.verticalCenter
Text {
text: row.dev ? (row.dev.deviceName || row.dev.name || row.dev.address || "Device") : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 12
elide: Text.ElideRight
width: parent.width
}
Text {
visible: row.statusText !== ""
text: row.statusText
color: row.statusColor
font.family: root.bar.fontFamily
font.pixelSize: 10
elide: Text.ElideRight
width: parent.width
}
}
}
}
}