features/monitor_options #5
@@ -79,6 +79,7 @@ Item {
|
|||||||
bonded: !!d.bonded,
|
bonded: !!d.bonded,
|
||||||
trusted: !!d.trusted,
|
trusted: !!d.trusted,
|
||||||
pairing: !!d.pairing,
|
pairing: !!d.pairing,
|
||||||
|
state: d.state,
|
||||||
batteryAvailable: !!d.batteryAvailable,
|
batteryAvailable: !!d.batteryAvailable,
|
||||||
battery: d.battery || 0
|
battery: d.battery || 0
|
||||||
}
|
}
|
||||||
@@ -105,19 +106,107 @@ Item {
|
|||||||
return null
|
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) {
|
function connectDevice(row) {
|
||||||
const d = root.deviceForAddress(row.address)
|
const d = root.deviceForAddress(row.address)
|
||||||
if (!d) return
|
if (!d) return
|
||||||
// Already-paired devices just reconnect; a fresh discovery result
|
// Already-paired devices just reconnect; a fresh discovery result
|
||||||
// has to pair first (BlueZ's own agent handles the SSP/passkey
|
// has to pair first (BlueZ's own agent handles the SSP/passkey
|
||||||
// handshake — there's no PIN-entry surface exposed by this API).
|
// handshake — there's no PIN-entry surface exposed by this API).
|
||||||
if (row.paired || row.bonded || row.trusted) d.connect()
|
if (row.paired || row.bonded || row.trusted) {
|
||||||
else d.pair()
|
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) {
|
function disconnectDevice(row) {
|
||||||
const d = root.deviceForAddress(row.address)
|
const d = root.deviceForAddress(row.address)
|
||||||
if (d) d.disconnect()
|
if (!d) return
|
||||||
|
root.pendingAction = { address: row.address, kind: "disconnect" }
|
||||||
|
pendingTimeoutTimer.restart()
|
||||||
|
d.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgetDevice(row) {
|
function forgetDevice(row) {
|
||||||
@@ -125,6 +214,18 @@ Item {
|
|||||||
if (!d) return
|
if (!d) return
|
||||||
if (d.connected) d.disconnect()
|
if (d.connected) d.disconnect()
|
||||||
d.forget()
|
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
|
readonly property bool anyConnected: root.connectedRows.length > 0
|
||||||
@@ -203,8 +304,13 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One device row. `showForget` hides the forget (x) button for
|
// One device row. `showForget` gates the whole trailing action cluster —
|
||||||
// discovered-but-unpaired devices, which have nothing to forget yet.
|
// 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 {
|
component DeviceRow: Rectangle {
|
||||||
id: devRow
|
id: devRow
|
||||||
required property var dev
|
required property var dev
|
||||||
@@ -213,6 +319,27 @@ Item {
|
|||||||
width: parent ? parent.width : 0
|
width: parent ? parent.width : 0
|
||||||
radius: 0
|
radius: 0
|
||||||
implicitHeight: rowContent.implicitHeight + 12
|
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):
|
// Hover checked first (same ordering as Volume.qml's DeviceRow):
|
||||||
// hovering always shows a solid Colors.primary fill, even for an
|
// hovering always shows a solid Colors.primary fill, even for an
|
||||||
// already-connected row, so the primaryText used below never has to
|
// already-connected row, so the primaryText used below never has to
|
||||||
@@ -270,6 +397,17 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
readonly property string statusText: {
|
readonly property string statusText: {
|
||||||
if (devRow.dev.pairing) return "Pairing…"
|
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)
|
if (devRow.dev.connected && devRow.dev.batteryAvailable)
|
||||||
return Math.round(devRow.dev.battery * 100) + "%"
|
return Math.round(devRow.dev.battery * 100) + "%"
|
||||||
if (devRow.dev.connected) return "Connected"
|
if (devRow.dev.connected) return "Connected"
|
||||||
@@ -283,9 +421,91 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
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
|
id: forgetIcon
|
||||||
visible: devRow.showForget
|
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"
|
text: "close"
|
||||||
font.family: Appearance.iconFontFamilyRounded
|
font.family: Appearance.iconFontFamilyRounded
|
||||||
font.variableAxes: Appearance.iconFontAxes
|
font.variableAxes: Appearance.iconFontAxes
|
||||||
@@ -293,6 +513,7 @@ Item {
|
|||||||
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
color: rowMouse.containsMouse ? Colors.primaryText : Colors.muted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: rowMouse
|
id: rowMouse
|
||||||
@@ -302,22 +523,16 @@ Item {
|
|||||||
height: rowContent.height + 12
|
height: rowContent.height + 12
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
// forgetIcon has no MouseArea of its own — nested inside
|
// Dispatches on devRow.hoveredAction rather than its own
|
||||||
// rowContent, which sits underneath this full-row area in
|
// MouseArea per icon — see that property's comment above.
|
||||||
// stacking order, so a separate MouseArea there would just be
|
onClicked: {
|
||||||
// shadowed (see the identical fix in NetworkIndicator.qml's
|
switch (devRow.hoveredAction) {
|
||||||
// NetworkRow). Dispatch by click position against this single
|
case "forget": root.forgetDevice(devRow.dev); break
|
||||||
// area instead.
|
case "trust": root.setTrusted(devRow.dev, !devRow.dev.trusted); break
|
||||||
onClicked: (mouse) => {
|
case "disconnect": root.disconnectDevice(devRow.dev); break
|
||||||
if (devRow.showForget && forgetIcon.visible) {
|
default: devRow.activate()
|
||||||
const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x
|
|
||||||
if (mouse.x >= forgetLeft - 8) {
|
|
||||||
root.forgetDevice(devRow.dev)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
devRow.activate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,7 +542,11 @@ Item {
|
|||||||
id: panel
|
id: panel
|
||||||
visible: false
|
visible: false
|
||||||
color: "transparent"
|
color: "transparent"
|
||||||
readonly property int contentWidth: 244
|
// 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
|
readonly property int contentPadding: 16
|
||||||
implicitWidth: contentWidth + contentPadding * 2
|
implicitWidth: contentWidth + contentPadding * 2
|
||||||
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||||
@@ -470,6 +689,42 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0
|
visible: root.adapter !== null && root.adapter.enabled && root.deviceRows.length === 0
|
||||||
@@ -479,9 +734,9 @@ Item {
|
|||||||
color: Colors.muted
|
color: Colors.muted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paired/Available — capped height, scrolls once there are
|
// Available — freshly discovered, unpaired devices only.
|
||||||
// more devices than fit rather than growing the popup
|
// Capped height, scrolls once there are more than fit rather
|
||||||
// indefinitely.
|
// than growing the popup indefinitely.
|
||||||
Flickable {
|
Flickable {
|
||||||
id: deviceListFlick
|
id: deviceListFlick
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -507,32 +762,6 @@ Item {
|
|||||||
bottomPadding: 3
|
bottomPadding: 3
|
||||||
spacing: 6
|
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 {
|
Column {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 4
|
spacing: 4
|
||||||
|
|||||||
Reference in New Issue
Block a user