514 lines
20 KiB
QML
514 lines
20 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,
|
|
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
|
|
}
|
|
|
|
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) d.connect()
|
|
else d.pair()
|
|
}
|
|
|
|
function disconnectDevice(row) {
|
|
const d = root.deviceForAddress(row.address)
|
|
if (d) d.disconnect()
|
|
}
|
|
|
|
function forgetDevice(row) {
|
|
const d = root.deviceForAddress(row.address)
|
|
if (!d) return
|
|
if (d.connected) d.disconnect()
|
|
d.forget()
|
|
}
|
|
|
|
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` hides the forget (x) button for
|
|
// discovered-but-unpaired devices, which have nothing to forget yet.
|
|
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
|
|
color: devRow.dev.connected
|
|
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
|
: (rowMouse.containsMouse ? Colors.primary : "transparent")
|
|
|
|
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.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
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: forgetIcon
|
|
visible: devRow.showForget
|
|
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
|
|
// forgetIcon has no MouseArea of its own — nested inside
|
|
// rowContent, which sits underneath this full-row area in
|
|
// stacking order, so a separate MouseArea there would just be
|
|
// shadowed (see the identical fix in NetworkIndicator.qml's
|
|
// NetworkRow). Dispatch by click position against this single
|
|
// area instead.
|
|
onClicked: (mouse) => {
|
|
if (devRow.showForget && forgetIcon.visible) {
|
|
const forgetLeft = devRow.mapFromItem(forgetIcon, 0, 0).x
|
|
if (mouse.x >= forgetLeft - 8) {
|
|
root.forgetDevice(devRow.dev)
|
|
return
|
|
}
|
|
}
|
|
devRow.activate()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Panel -------------------------------------------------------------
|
|
|
|
PopupWindow {
|
|
id: panel
|
|
visible: false
|
|
color: "transparent"
|
|
readonly property int contentWidth: 244
|
|
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
|
|
}
|
|
|
|
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 {
|
|
width: parent.width
|
|
spacing: 2
|
|
Repeater {
|
|
model: root.connectedRows
|
|
DeviceRow {
|
|
required property var modelData
|
|
dev: modelData
|
|
showForget: true
|
|
onActivate: root.disconnectDevice(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
|
|
}
|
|
|
|
// Paired/Available — capped height, scrolls once there are
|
|
// more devices 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
|
|
width: deviceListFlick.width
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|