Files
dotfiles/dotfiles/.config/quickshell/modules/bar/BluetoothIndicator.qml

796 lines
34 KiB
QML

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,
state: d.state,
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
}
// --- Action feedback ---------------------------------------------------
// BlueZ operations here are fire-and-forget from QML's side — connect()/
// disconnect()/pair()/forget() report failure only via a C++-side log
// line (see quickshell's bluetooth/device.cpp), nothing reaches QML. So
// "did it work" has to be inferred from the device's own properties
// instead of a success/failure callback. Shown as a line right under the
// specific row it's about (DeviceRow.statusText below) rather than a
// panel-wide banner — actionStatusAddress is how a row knows a message
// is meant for it; since it's address-keyed rather than tied to a
// particular DeviceRow instance, the message still finds its row even
// when the device jumps sections (Paired → Connected on a successful
// connect, say), because that's a new DeviceRow for the same address.
// - busyStatus is purely reactive — it just reads pairing/state off
// whichever row is currently mid-operation, however that operation
// was triggered. It's not shown directly (each row already derives
// the same "Connecting…"/"Disconnecting…" off its own dev.state);
// it only exists here to tell resolvePendingAction when to fire.
// - pendingAction+the busyStatus watcher below is how a *specific*
// click gets its own outcome message: it snapshots what was asked
// for, waits for busyStatus to clear, then checks whether the
// expected end state (connected/paired) actually landed.
readonly property string busyStatus: {
for (const r of root.deviceRows) {
if (r.pairing) return "pairing:" + r.address
if (r.state === BluetoothDeviceState.Connecting) return "connecting:" + r.address
if (r.state === BluetoothDeviceState.Disconnecting) return "disconnecting:" + r.address
}
return ""
}
property var pendingAction: null
property string actionStatus: ""
property string actionStatusAddress: ""
// Checks whether pendingAction's expected end state actually landed and
// reports it, then clears pendingAction. Called both when busyStatus
// clears normally and from pendingTimeoutTimer below — an operation
// that resolves inside a single binding re-evaluation (a device already
// in range reconnecting near-instantly, say) can flip busyStatus from ""
// straight back to "" without ever visibly changing, which would
// otherwise leave pendingAction stuck forever and misattribute whatever
// busyStatus transition happens to come along next.
function resolvePendingAction() {
if (!root.pendingAction) return
const p = root.pendingAction
root.pendingAction = null
const d = root.deviceForAddress(p.address)
root.actionStatusAddress = p.address
if (p.kind === "disconnect") {
root.actionStatus = (d && !d.connected) ? "Disconnected" : "Couldn't disconnect"
} else if (p.kind === "pair") {
root.actionStatus = (d && d.paired) ? "Paired" : "Couldn't pair"
} else {
root.actionStatus = (d && d.connected) ? "Connected" : "Couldn't connect"
}
actionStatusTimer.restart()
}
onBusyStatusChanged: if (root.busyStatus === "") root.resolvePendingAction()
Timer {
id: actionStatusTimer
interval: 3500
repeat: false
onTriggered: root.actionStatus = ""
}
// Backstop for the "resolved before busyStatus ever visibly changed"
// case above, and a genuine timeout for an operation that's just stuck
// (device out of range, etc.) — either way, restarted whenever
// pendingAction is (re)set, so it always reflects the latest click.
Timer {
id: pendingTimeoutTimer
interval: 8000
repeat: false
onTriggered: root.resolvePendingAction()
}
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) {
root.pendingAction = { address: row.address, kind: "connect" }
pendingTimeoutTimer.restart()
d.connect()
} else {
root.pendingAction = { address: row.address, kind: "pair" }
pendingTimeoutTimer.restart()
d.pair()
}
}
function disconnectDevice(row) {
const d = root.deviceForAddress(row.address)
if (!d) return
root.pendingAction = { address: row.address, kind: "disconnect" }
pendingTimeoutTimer.restart()
d.disconnect()
}
function forgetDevice(row) {
const d = root.deviceForAddress(row.address)
if (!d) return
if (d.connected) d.disconnect()
d.forget()
// No feedback line here — forgetting removes this row from every
// list immediately, so there's nothing left to show a message
// under. The row vanishing is the confirmation.
}
function setTrusted(row, trusted) {
const d = root.deviceForAddress(row.address)
if (!d) return
d.trusted = trusted
root.actionStatusAddress = row.address
root.actionStatus = trusted ? "Trusting" : "No longer trusting"
actionStatusTimer.restart()
}
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` gates the whole trailing action cluster —
// trust toggle, disconnect, forget — for discovered-but-unpaired devices,
// which have none of those to act on yet (a plain click there pairs
// instead, via activate()). For a paired/connected row, the row body
// itself still connects/disconnects (activate()) same as before; the
// actions below are the explicit, discoverable versions of that plus
// the two this row previously had no way to reach at all.
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
// Which action icon (if any) the pointer is currently over — drives
// both that icon's own hover color below and rowMouse's click
// dispatch. Derived from rowMouse.mouseX/containsMouse rather than a
// MouseArea on each icon: those icons live inside rowContent, which
// sits underneath rowMouse in stacking order, so a nested MouseArea
// there would just be shadowed by it (same reason
// NetworkIndicator.qml's NetworkRow dispatches by position too).
// Checked right-to-left since that's the order the icons are laid
// out in, so an earlier icon's "from here to the row's right edge"
// zone doesn't swallow the ones after it.
readonly property string hoveredAction: {
if (!rowMouse.containsMouse) return ""
if (devRow.showForget && forgetIcon.visible
&& rowMouse.mouseX >= devRow.mapFromItem(forgetIcon, 0, 0).x - 8) return "forget"
if (devRow.showForget && trustIcon.visible
&& rowMouse.mouseX >= devRow.mapFromItem(trustIcon, 0, 0).x - 8) return "trust"
if (disconnectIcon.visible
&& rowMouse.mouseX >= devRow.mapFromItem(disconnectIcon, 0, 0).x - 8) return "disconnect"
return ""
}
// Hover checked first (same ordering as Volume.qml's DeviceRow):
// hovering always shows a solid Colors.primary fill, even for an
// already-connected row, so the primaryText used below never has to
// sit on top of the dimmer translucent tint instead.
color: rowMouse.containsMouse ? Colors.primary
: (devRow.dev.connected ? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18) : "transparent")
// Hover-only outline, offset outside the row rather than drawn on
// its own edge — same treatment as NetworkIndicator.qml's NetworkRow.
// The lists this feeds are inset by 3px (see deviceListColumn and
// the Connected section below) so this has room without being
// cropped by deviceListFlick's clip.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: rowMouse.containsMouse ? 1 : 0
}
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.state === BluetoothDeviceState.Connecting) return "Connecting…"
if (devRow.dev.state === BluetoothDeviceState.Disconnecting) return "Disconnecting…"
// The outcome of the last action taken on this
// specific device (connect/disconnect/pair/trust) —
// see the "Action feedback" comment above
// root.busyStatus. Matched by address rather than by
// this being "the same row" that was clicked, since
// a successful connect/disconnect moves the device
// to a different section (a new DeviceRow entirely).
if (root.actionStatusAddress === devRow.dev.address && root.actionStatus !== "")
return root.actionStatus
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
}
}
// Each action below is a fixed-size slot with its own
// hover-halo outline — offset outside the slot rather than drawn
// on its own edge, same treatment as devRow's own outline above
// and every other hoverable box in this file (ToggleSwitch,
// DeviceRow itself, etc.) — instead of just the color swap the
// row's own icons get.
// Disconnect without forgetting — same effect as clicking the
// row body on a Connected row (activate() below calls
// disconnectDevice too), just given its own explicit, always-
// visible icon instead of relying on that being guessed.
Item {
id: disconnectIcon
visible: devRow.showForget && devRow.dev.connected
Layout.preferredWidth: 20
Layout.preferredHeight: 20
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: devRow.hoveredAction === "disconnect" ? 1 : 0
}
Text {
anchors.centerIn: parent
text: "link_off"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
// Stays primaryText (dark) rather than switching to
// Colors.primary while targeted — devRow's own fill is
// already Colors.primary on hover, so that would've put
// a primary-colored glyph on a primary-colored
// background. The outline Rectangle above is what shows
// which slot is targeted.
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
}
}
// Trust toggle — full brightness when trusted, dimmed when not,
// same "state via opacity" convention PowerButton.qml uses for
// its disabled entries.
Item {
id: trustIcon
visible: devRow.showForget
Layout.preferredWidth: 20
Layout.preferredHeight: 20
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: devRow.hoveredAction === "trust" ? 1 : 0
}
Text {
anchors.centerIn: parent
text: "verified_user"
opacity: devRow.dev.trusted ? 1 : 0.4
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
}
}
Item {
id: forgetIcon
visible: devRow.showForget
Layout.preferredWidth: 20
Layout.preferredHeight: 20
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: devRow.hoveredAction === "forget" ? 1 : 0
}
Text {
anchors.centerIn: parent
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
// Dispatches on devRow.hoveredAction rather than its own
// MouseArea per icon — see that property's comment above.
onClicked: {
switch (devRow.hoveredAction) {
case "forget": root.forgetDevice(devRow.dev); break
case "trust": root.setTrusted(devRow.dev, !devRow.dev.trusted); break
case "disconnect": root.disconnectDevice(devRow.dev); break
default: devRow.activate()
}
}
}
}
// --- Panel -------------------------------------------------------------
PopupWindow {
id: panel
visible: false
color: "transparent"
// Wider than the other panels' usual 244 — each device row now ends
// in a row of three 20px action slots (disconnect/trust/forget)
// instead of a single close icon, and the name column needs room to
// not immediately start eliding against that.
readonly property int contentWidth: 300
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
}
// Spins continuously while root.adapter.discovering is
// true — a persistent "still searching" cue independent
// of the list itself, unlike the "Scanning for devices…"
// text below (which only shows while the device list is
// still empty).
Text {
id: scanSpinner
visible: root.adapter !== null && root.adapter.discovering
text: "progress_activity"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 16
color: Colors.muted
RotationAnimation on rotation {
running: scanSpinner.visible
loops: Animation.Infinite
from: 0
to: 360
duration: 900
}
}
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 {
// Inset 3px on each side to match deviceListColumn
// below, so DeviceRow's hover halo has room here too.
x: 3
width: parent.width - 6
spacing: 2
Repeater {
model: root.connectedRows
DeviceRow {
required property var modelData
dev: modelData
showForget: true
onActivate: root.disconnectDevice(dev)
}
}
}
}
// Paired — always shown in full, same as Connected above and
// for the same reason: these are one click away from
// reconnecting (root.connectDevice reconnects rather than
// re-pairing whenever a row is already paired/bonded/trusted),
// so they shouldn't be buried in the capped-height scroll area
// below or hidden behind an active scan turning up other
// nearby devices.
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 {
// Inset 3px on each side to match deviceListColumn
// below, so DeviceRow's hover halo has room here too.
x: 3
width: parent.width - 6
spacing: 2
Repeater {
model: root.knownRows
DeviceRow {
required property var modelData
dev: modelData
showForget: true
onActivate: root.connectDevice(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
}
// Available — freshly discovered, unpaired devices only.
// Capped height, scrolls once there are more 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
// Inset 3px on every side so DeviceRow's hover halo
// has room without being cropped by deviceListFlick's
// clip. Left/right needs the manual x/width shift
// (children bind width: parent.width, which ignores
// Column's own padding); top/bottom just needs
// padding, since that's along Column's own layout
// axis and Flickable's contentHeight tracks it.
x: 3
width: deviceListFlick.width - 6
topPadding: 3
bottomPadding: 3
spacing: 6
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)
}
}
}
}
}
}
}
}
}
}