mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-06 04:29:34 +02:00
Each device row is now a two-line entry: name on top, status on the bottom (Connected · 80%, Connecting…, Pairing…, Disconnecting…, Failed, Available · click to pair, or Paired). Status text and the bluetooth icon both color-code by state — connected/active draws in foreground, idle in dim, failed in urgent. Pending click attempts arm a 10s timer; if the device hasn't reached the expected state by then the row flips to 'Failed' with an urgent color so you can see the connect attempt didn't take. State change listeners clear the pending flag the moment the device actually connects or pairs. Hover tooltips and right-click-to-forget moved into the row so the behavior is self-contained.
346 lines
11 KiB
QML
346 lines
11 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
|
|
|
|
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 string icon: {
|
|
if (!adapter) return ""
|
|
if (!adapter.enabled) return ""
|
|
if (connectedDevices.length > 0) return ""
|
|
return ""
|
|
}
|
|
|
|
visible: adapter !== null
|
|
implicitWidth: button.implicitWidth
|
|
implicitHeight: button.implicitHeight
|
|
|
|
Common.WidgetButton {
|
|
id: button
|
|
anchors.fill: parent
|
|
bar: root.bar
|
|
text: root.icon
|
|
horizontalMargin: 8.5
|
|
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"
|
|
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)"
|
|
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"
|
|
foreground: root.bar.foreground
|
|
horizontalPadding: 10
|
|
verticalPadding: 4
|
|
active: root.adapter && root.adapter.enabled
|
|
onClicked: if (root.adapter) root.adapter.enabled = !root.adapter.enabled
|
|
}
|
|
}
|
|
}
|
|
|
|
// Paired / known devices.
|
|
Repeater {
|
|
model: root.knownDevices
|
|
DeviceRow {
|
|
required property var modelData
|
|
width: parent.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: parent.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: parent.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 } }
|
|
|
|
ToolTip.visible: rowMouse.containsMouse && tooltipBody !== ""
|
|
ToolTip.text: tooltipBody
|
|
ToolTip.delay: 400
|
|
readonly property string tooltipBody:
|
|
isDiscovered ? "Click to pair and connect"
|
|
: isConnected ? "Click to disconnect · right-click to forget"
|
|
: "Click to connect · right-click to forget"
|
|
|
|
Row {
|
|
id: rowContent
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.leftMargin: 10
|
|
anchors.rightMargin: 10
|
|
spacing: 10
|
|
|
|
Text {
|
|
text: row.isConnected ? "" : ""
|
|
color: row.statusColor
|
|
font.family: root.bar.fontFamily
|
|
font.pixelSize: 16
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Column {
|
|
spacing: 1
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width - parent.children[0].width - parent.spacing
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
row.dev.pair()
|
|
return
|
|
}
|
|
if (row.isConnected) {
|
|
row.pendingAction = 2
|
|
failureTimer.stop()
|
|
row.dev.disconnect()
|
|
} else {
|
|
row.pendingAction = 1
|
|
row.failureReason = ""
|
|
failureTimer.restart()
|
|
row.dev.connect()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|