pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import QtQml.Models import Quickshell import Quickshell.Bluetooth import ZShell.Components import ZShell.Config import qs.Services import qs.Components import qs.Helpers CustomClippingRect { id: root implicitHeight: layout.implicitHeight + layout.anchors.margins * 2 implicitWidth: 500 ColumnLayout { id: layout anchors.fill: parent anchors.margins: Tokens.padding.larger spacing: Tokens.spacing.extraSmall / 2 CustomText { Layout.rightMargin: Tokens.padding.extraSmall Layout.topMargin: Tokens.padding.normal font.pointSize: Tokens.font.size.large text: qsTr("Bluetooth") } Toggle { checked: Bluetooth.defaultAdapter?.enabled ?? false // qmllint disable unresolved-type first: true subtext: qsTr("Toggle bluetooth device state") text: checked ? qsTr("Enabled") : qsTr("Disabled") onToggled: { const adapter = Bluetooth.defaultAdapter; // qmllint disable unresolved-type if (adapter) adapter.enabled = checked; } } Toggle { checked: Bluetooth.defaultAdapter?.discovering ?? false // qmllint disable unresolved-type last: rows.sortedConnected.length === 0 subtext: qsTr("Enable scanning for new devices") text: qsTr("Discover") onToggled: { const adapter = Bluetooth.defaultAdapter; // qmllint disable unresolved-type if (adapter) adapter.discovering = checked; } } ListView { id: rows readonly property var sortedConnected: { const byName = (a, b) => a.name.localeCompare(b.name); return [...Bluetooth.devices.values].filter(d => d.connected).sort(byName).slice(0, 5); } Layout.fillWidth: true Layout.preferredHeight: contentHeight interactive: false section.property: "connected" spacing: Tokens.spacing.extraSmall / 2 add: Transition { Anim { from: 0 property: "opacity" to: 1 } } delegate: DelegateChooser { role: "isDivider" DelegateChoice { roleValue: true delegate: StatusDivider { } } DelegateChoice { delegate: BluetoothItem { list: rows width: rows.width } } } displaced: Transition { Anim { easing.type: Easing.OutCubic property: "y" } } model: ScriptModel { objectProp: "address" values: { const byName = (a, b) => a.name.localeCompare(b.name); return [...[...Bluetooth.devices.values].filter(d => d.connected).sort(byName).slice(0, 5), { isDivider: true }, ...[...Bluetooth.devices.values].filter(d => !d.connected).sort(byName).slice(0, 5)]; } } move: Transition { Anim { easing.type: Easing.OutCubic property: "y" } } remove: Transition { Anim { property: "opacity" to: 0 } } section.delegate: Rectangle { required property string section color: "transparent" height: childrenRect.height width: ListView.view.width CustomText { text: parent.section } } } } component BluetoothItem: ConnectedRect { id: btItem readonly property int connectedCount: rows.sortedConnected.length readonly property bool connecting: Network.connectingNetwork === modelData property int horizontalPadding: Tokens.padding.largeIncreased readonly property bool inConnectedGroup: index < connectedCount required property int index required property ListView list readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting // qmllint disable unresolved-type required property BluetoothDevice modelData property int verticalPadding: Tokens.padding.smaller first: !inConnectedGroup && index === connectedCount + 1 implicitHeight: rowLayout.implicitHeight + verticalPadding * 2 last: inConnectedGroup ? index === connectedCount - 1 : index === list.count - 1 radius: Tokens.rounding.small Timer { interval: 50 running: btItem.ListView.delayRemove onTriggered: btItem.ListView.delayRemove = false } Connections { function onConnectedChanged(): void { if (!btItem.modelData.connected) btItem.ListView.delayRemove = true; } function onPairedChanged(): void { if (btItem.modelData.paired) btItem.modelData.connect(); } target: btItem.modelData } RowLayout { id: rowLayout anchors.fill: parent anchors.leftMargin: btItem.horizontalPadding anchors.rightMargin: btItem.horizontalPadding Column { id: column Layout.fillWidth: true CustomText { Layout.fillWidth: true Layout.leftMargin: Tokens.spacing.extraSmall Layout.rightMargin: Tokens.spacing.extraSmall elide: Text.ElideRight text: btItem.modelData.name } CustomText { id: subtext anchors.left: parent.left anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight font.pointSize: Tokens.font.size.small text: btItem.modelData.connected ? (btItem.modelData.batteryAvailable ? qsTr("%1 battery left").arg(Math.round(btItem.modelData.battery * 100) + "%") : qsTr("Battery status unknown")) : btItem.modelData.address } } Item { Layout.alignment: Qt.AlignRight implicitHeight: icon.height implicitWidth: icon.width MaterialIcon { id: icon font.pointSize: Tokens.font.size.large opacity: btItem.connecting ? 0 : 1 text: btItem.modelData.batteryAvailable ? Icons.getBatteryIcon(btItem.modelData.battery) : "battery_unknown" visible: btItem.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type Behavior on opacity { Anim { } } } Loader { active: opacity > 0 anchors.fill: parent opacity: btItem.connecting ? 1 : 0 Behavior on opacity { Anim { } } sourceComponent: CircularIndicator { bgColor: "transparent" running: true } } } ButtonRow { spacing: Tokens.spacing.small IconTextButton { font.pointSize: Tokens.font.size.small icon: btItem.modelData.connected ? "link_off" : btItem.modelData.paired ? "link" : "add_link" text: btItem.modelData.connected ? qsTr("Disconnect") : btItem.modelData.paired ? qsTr("Connect") : qsTr("Pair") onClicked: { if (btItem.modelData.connected) btItem.modelData.disconnect(); else if (btItem.modelData.paired) btItem.modelData.connect(); else if (!btItem.modelData.paired) btItem.modelData.pair(); else if (btItem.modelData.pairing) btItem.modelData.cancelPair(); } } } } } component ConnectedRect: CustomRect { id: bg property bool first property bool last bottomLeftRadius: last ? Tokens.rounding.large : Tokens.rounding.extraSmall bottomRightRadius: last ? Tokens.rounding.large : Tokens.rounding.extraSmall color: Colors.tPalette.m3surfaceContainer topLeftRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall topRightRadius: first ? Tokens.rounding.large : Tokens.rounding.extraSmall Behavior on bottomLeftRadius { Anim { } } Behavior on bottomRightRadius { Anim { } } Behavior on topLeftRadius { Anim { } } Behavior on topRightRadius { Anim { } } } component StatusDivider: CustomText { bottomPadding: Tokens.spacing.extraSmall color: Colors.palette.m3onSurfaceVariant font.pointSize: Tokens.font.size.smaller text: { const devices = Bluetooth.devices.values; // qmllint disable unresolved-type const connectedCount = devices.filter(d => d.connected).length; let available = qsTr("%1 device%2 available").arg(devices.length - connectedCount).arg(devices.length === 1 ? "" : "s"); return available; } topPadding: Tokens.spacing.small } component Toggle: CustomSwitch { id: toggleItem readonly property alias bg: bg property alias first: bg.first property alias last: bg.last property string subtext Layout.fillWidth: true cLayer: 2 horizontalPadding: Tokens.padding.largeIncreased implicitHeight: Math.max(implicitContentHeight, implicitIndicatorHeight) + verticalPadding * 2 implicitWidth: implicitContentWidth + implicitIndicatorWidth + horizontalPadding * 2 indicator.anchors.right: right indicator.anchors.rightMargin: toggleItem.horizontalPadding indicator.anchors.verticalCenter: verticalCenter verticalPadding: Tokens.padding.normal background: ConnectedRect { id: bg StateLayer { id: stateLayer manualPressOverride: toggleItem.pressed } } contentItem: Item { anchors.left: parent.left anchors.leftMargin: toggleItem.horizontalPadding anchors.right: toggleItem.indicator.left anchors.rightMargin: Tokens.spacing.normal implicitHeight: column.implicitHeight implicitWidth: column.implicitWidth Column { id: column anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 0 CustomText { id: label anchors.left: parent.left anchors.right: parent.right elide: Text.ElideRight font: { const f = Qt.font(label.font); f.pointSize = Tokens.font.size.smaller; return f; } text: toggleItem.text } CustomText { id: subtext anchors.left: parent.left anchors.right: parent.right color: Colors.palette.m3outline elide: Text.ElideRight font: { const f = Qt.font(subtext.font); f.pointSize = Tokens.font.size.small; return f; } text: toggleItem.subtext visible: toggleItem.subtext } } } onPressed: stateLayer.press(stateLayer.mouseX, stateLayer.mouseY) } }