1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-07 14:05:38 -05:00

bluetooth: switch to native quickshell bluetooth service

removes dependency on bluetoothctl commands
This commit is contained in:
bbedward
2025-07-15 12:43:22 -04:00
parent 1396b0b582
commit b7da76147f
8 changed files with 1030 additions and 761 deletions

View File

@@ -14,7 +14,6 @@ Singleton {
property var applicationsByExec: ({})
property bool ready: false
// Pre-prepared fuzzy search data
property var preppedApps: []
@@ -33,7 +32,6 @@ Singleton {
function loadApplications() {
// Trigger rescan on next frame to avoid blocking
Qt.callLater(function() {
var allApps = Array.from(DesktopEntries.applications.values)
@@ -41,7 +39,6 @@ Singleton {
.filter(app => !app.noDisplay)
.sort((a, b) => a.name.localeCompare(b.name))
// Build lookup maps
var byName = {}
var byExec = {}
@@ -49,7 +46,6 @@ Singleton {
var app = applications[i]
byName[app.name.toLowerCase()] = app
// Clean exec string for lookup
var execProp = app.execString || ""
var cleanExec = execProp ? execProp.replace(/%[fFuU]/g, "").trim() : ""
if (cleanExec) {
@@ -60,7 +56,6 @@ Singleton {
applicationsByName = byName
applicationsByExec = byExec
// Prepare fuzzy search data
preppedApps = applications.map(app => ({
name: Fuzzy.prepare(app.name || ""),
comment: Fuzzy.prepare(app.comment || ""),
@@ -84,12 +79,10 @@ Singleton {
return []
}
// Use fuzzy search with both name and comment fields
var results = Fuzzy.go(query, preppedApps, {
all: false,
keys: ["name", "comment"],
scoreFn: r => {
// Prioritize name matches over comment matches
var nameScore = r[0] ? r[0].score : 0
var commentScore = r[1] ? r[1].score : 0
return nameScore > 0 ? nameScore * 0.9 + commentScore * 0.1 : commentScore * 0.5
@@ -97,7 +90,6 @@ Singleton {
limit: 50
})
// Extract the desktop entries from results
return results.map(r => r.obj.entry)
}
@@ -180,7 +172,6 @@ Singleton {
return false
}
// DesktopEntry objects have an execute() method
if (typeof app.execute === "function") {
app.execute()
return true

View File

@@ -11,16 +11,12 @@ Singleton {
property var audioSinks: []
property string currentAudioSink: ""
// Microphone properties
property int micLevel: 50
property var audioSources: []
property string currentAudioSource: ""
// Device scanning control
property bool deviceScanningEnabled: false
property bool initialScanComplete: false
// Real Audio Control
Process {
id: volumeChecker
command: ["bash", "-c", "pactl get-sink-volume @DEFAULT_SINK@ | grep -o '[0-9]*%' | head -1 | tr -d '%'"]
@@ -36,7 +32,6 @@ Singleton {
}
}
// Microphone level checker
Process {
id: micLevelChecker
command: ["bash", "-c", "pactl get-source-volume @DEFAULT_SOURCE@ | grep -o '[0-9]*%' | head -1 | tr -d '%'"]
@@ -68,7 +63,6 @@ Singleton {
for (let line of lines) {
line = line.trim()
// New sink starts
if (line.startsWith('Sink #')) {
if (currentSink && currentSink.name && currentSink.id) {
sinks.push(currentSink)
@@ -84,39 +78,31 @@ Singleton {
active: false
}
}
// Get the Name field
else if (line.startsWith('Name: ') && currentSink) {
currentSink.name = line.replace('Name: ', '').trim()
}
// Get the Description field (main display name)
else if (line.startsWith('Description: ') && currentSink) {
currentSink.description = line.replace('Description: ', '').trim()
}
// Get device.description as fallback
else if (line.includes('device.description = ') && currentSink && !currentSink.description) {
currentSink.description = line.replace('device.description = ', '').replace(/"/g, '').trim()
}
// Get node.nick as another fallback option
else if (line.includes('node.nick = ') && currentSink && !currentSink.description) {
currentSink.nick = line.replace('node.nick = ', '').replace(/"/g, '').trim()
}
}
// Add the last sink
if (currentSink && currentSink.name && currentSink.id) {
sinks.push(currentSink)
}
// Process display names
for (let sink of sinks) {
let displayName = sink.description
// If no good description, try nick
if (!displayName || displayName === sink.name) {
displayName = sink.nick
}
// Still no good name? Fall back to smart defaults
if (!displayName || displayName === sink.name) {
if (sink.name.includes("analog-stereo")) displayName = "Built-in Speakers"
else if (sink.name.includes("bluez")) displayName = "Bluetooth Audio"
@@ -136,7 +122,6 @@ Singleton {
}
}
// Audio source (microphone) lister
Process {
id: audioSourceLister
command: ["pactl", "list", "sources"]
@@ -153,7 +138,6 @@ Singleton {
for (let line of lines) {
line = line.trim()
// New source starts
if (line.startsWith('Source #')) {
if (currentSource && currentSource.name && currentSource.id) {
sources.push(currentSource)
@@ -165,23 +149,19 @@ Singleton {
active: false
}
}
// Source name
else if (line.startsWith('Name: ') && currentSource) {
currentSource.name = line.replace('Name: ', '')
}
// Description (display name)
else if (line.startsWith('Description: ') && currentSource) {
let desc = line.replace('Description: ', '')
currentSource.displayName = desc
}
}
// Add the last source
if (currentSource && currentSource.name && currentSource.id) {
sources.push(currentSource)
}
// Filter out monitor sources (we want actual input devices)
sources = sources.filter(source => !source.name.includes('.monitor'))
root.audioSources = sources
@@ -202,7 +182,6 @@ Singleton {
if (data.trim()) {
root.currentAudioSink = data.trim()
// Update active status in audioSinks
let updatedSinks = []
for (let sink of root.audioSinks) {
updatedSinks.push({
@@ -218,7 +197,6 @@ Singleton {
}
}
// Default source (microphone) checker
Process {
id: defaultSourceChecker
command: ["pactl", "get-default-source"]
@@ -230,7 +208,6 @@ Singleton {
if (data.trim()) {
root.currentAudioSource = data.trim()
// Update active status in audioSources
let updatedSources = []
for (let source of root.audioSources) {
updatedSources.push({
@@ -271,12 +248,10 @@ Singleton {
function setAudioSink(sinkName) {
console.log("Setting audio sink to:", sinkName)
// Use a more reliable approach instead of Qt.createQmlObject
sinkSetProcess.command = ["pactl", "set-default-sink", sinkName]
sinkSetProcess.running = true
}
// Dedicated process for setting audio sink
Process {
id: sinkSetProcess
running: false
@@ -285,7 +260,6 @@ Singleton {
console.log("Audio sink change exit code:", exitCode)
if (exitCode === 0) {
console.log("Audio sink changed successfully")
// Refresh current sink and list
defaultSinkChecker.running = true
if (root.deviceScanningEnabled) {
audioSinkLister.running = true
@@ -303,7 +277,6 @@ Singleton {
sourceSetProcess.running = true
}
// Dedicated process for setting audio source
Process {
id: sourceSetProcess
running: false
@@ -312,7 +285,6 @@ Singleton {
console.log("Audio source change exit code:", exitCode)
if (exitCode === 0) {
console.log("Audio source changed successfully")
// Refresh current source and list
defaultSourceChecker.running = true
if (root.deviceScanningEnabled) {
audioSourceLister.running = true
@@ -337,25 +309,21 @@ Singleton {
Component.onCompleted: {
console.log("AudioService: Starting initialization...")
// Do initial device scan
audioSinkLister.running = true
audioSourceLister.running = true
initialScanComplete = true
console.log("AudioService: Initialization complete")
}
// Control functions for managing device scanning
function enableDeviceScanning(enabled) {
console.log("AudioService: Device scanning", enabled ? "enabled" : "disabled")
root.deviceScanningEnabled = enabled
if (enabled && root.initialScanComplete) {
// Immediately scan when enabled
audioSinkLister.running = true
audioSourceLister.running = true
}
}
// Manual refresh function for when user opens audio settings
function refreshDevices() {
console.log("AudioService: Manual device refresh triggered")
audioSinkLister.running = true

View File

@@ -7,7 +7,6 @@ pragma ComponentBehavior: Bound
Singleton {
id: root
// Battery properties
property bool batteryAvailable: false
property int batteryLevel: 0
property string batteryStatus: "Unknown"
@@ -20,8 +19,6 @@ Singleton {
property int batteryCapacity: 0
property var powerProfiles: []
property string activePowerProfile: ""
// Check if battery is available
Process {
id: batteryAvailabilityChecker
command: ["bash", "-c", "ls /sys/class/power_supply/ | grep -E '^BAT' | head -1"]
@@ -58,7 +55,6 @@ Singleton {
if (text.trim() && text.trim() !== "fallback") {
parseBatteryInfo(text.trim())
} else {
// Fallback to simple methods
fallbackBatteryChecker.running = true
}
}
@@ -72,7 +68,6 @@ Singleton {
}
}
// Fallback battery checker using /sys files
Process {
id: fallbackBatteryChecker
command: ["bash", "-c", "if [ -f /sys/class/power_supply/BAT0/capacity ]; then BAT=BAT0; elif [ -f /sys/class/power_supply/BAT1/capacity ]; then BAT=BAT1; else echo 'no-battery'; exit 1; fi; echo \"percentage: $(cat /sys/class/power_supply/$BAT/capacity)%\"; echo \"state: $(cat /sys/class/power_supply/$BAT/status 2>/dev/null || echo Unknown)\"; if [ -f /sys/class/power_supply/$BAT/technology ]; then echo \"technology: $(cat /sys/class/power_supply/$BAT/technology)\"; fi; if [ -f /sys/class/power_supply/$BAT/cycle_count ]; then echo \"cycle-count: $(cat /sys/class/power_supply/$BAT/cycle_count)\"; fi"]
@@ -87,7 +82,6 @@ Singleton {
}
}
// Power profiles checker (for systems with power-profiles-daemon)
Process {
id: powerProfilesChecker
command: ["bash", "-c", "if command -v powerprofilesctl > /dev/null; then powerprofilesctl list 2>/dev/null; else echo 'not-available'; fi"]
@@ -170,7 +164,6 @@ Singleton {
for (let line of lines) {
line = line.trim()
if (line.includes('*')) {
// Active profile
let profileName = line.replace('*', '').trim()
if (profileName.includes(':')) {
profileName = profileName.split(':')[0].trim()
@@ -248,7 +241,6 @@ Singleton {
}
// Update timer
Timer {
interval: 30000
running: root.batteryAvailable

View File

@@ -1,6 +1,6 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Bluetooth
pragma Singleton
pragma ComponentBehavior: Bound
@@ -9,410 +9,466 @@ Singleton {
property bool bluetoothEnabled: false
property bool bluetoothAvailable: false
property var bluetoothDevices: []
property var availableDevices: []
readonly property list<BluetoothDevice> bluetoothDevices: []
readonly property list<BluetoothDevice> availableDevices: []
property bool scanning: false
property bool discoverable: false
// Real Bluetooth Management
Process {
id: bluetoothStatusChecker
command: ["bluetoothctl", "show"] // Use default controller
running: true
property var connectingDevices: ({})
stdout: StdioCollector {
onStreamFinished: {
root.bluetoothAvailable = text.trim() !== "" && !text.includes("No default controller")
root.bluetoothEnabled = text.includes("Powered: yes")
Component.onCompleted: {
refreshBluetoothState()
updateDevices()
}
if (root.bluetoothEnabled && root.bluetoothAvailable) {
bluetoothDeviceScanner.running = true
} else {
Connections {
target: Bluetooth
function onDefaultAdapterChanged() {
console.log("BluetoothService: Default adapter changed")
refreshBluetoothState()
updateDevices()
}
}
Connections {
target: Bluetooth.defaultAdapter
function onEnabledChanged() {
refreshBluetoothState()
updateDevices()
}
function onDiscoveringChanged() {
refreshBluetoothState()
updateDevices()
}
}
Connections {
target: Bluetooth.defaultAdapter ? Bluetooth.defaultAdapter.devices : null
function onModelReset() {
updateDevices()
}
function onItemAdded() {
updateDevices()
}
function onItemRemoved() {
updateDevices()
}
}
Connections {
target: Bluetooth.devices
function onModelReset() {
updateDevices()
}
function onItemAdded() {
updateDevices()
}
function onItemRemoved() {
updateDevices()
}
}
function refreshBluetoothState() {
root.bluetoothAvailable = Bluetooth.defaultAdapter !== null
root.bluetoothEnabled = Bluetooth.defaultAdapter ? Bluetooth.defaultAdapter.enabled : false
root.scanning = Bluetooth.defaultAdapter ? Bluetooth.defaultAdapter.discovering : false
root.discoverable = Bluetooth.defaultAdapter ? Bluetooth.defaultAdapter.discoverable : false
}
function updateDevices() {
if (!Bluetooth.defaultAdapter) {
clearDeviceList(root.bluetoothDevices)
clearDeviceList(root.availableDevices)
root.bluetoothDevices = []
root.availableDevices = []
return
}
let newPairedDevices = []
let newAvailableDevices = []
let allNativeDevices = []
let adapterDevices = Bluetooth.defaultAdapter.devices
if (adapterDevices.values) {
allNativeDevices = allNativeDevices.concat(adapterDevices.values)
}
if (Bluetooth.devices.values) {
for (let device of Bluetooth.devices.values) {
if (!allNativeDevices.some(d => d.address === device.address)) {
allNativeDevices.push(device)
}
}
}
Process {
id: bluetoothDeviceScanner
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([0-9A-F:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ! $name =~ ^/org/bluez ]]; then info=$(bluetoothctl info $mac); connected=$(echo \"$info\" | grep -m1 'Connected:' | awk '{print $2}'); battery=$(echo \"$info\" | grep -m1 'Battery Percentage:' | grep -o '[0-9]\\+'); echo \"$mac|$name|$connected|${battery:-}\"; fi; fi; done"]
running: false
for (let device of allNativeDevices) {
if (!device) continue
stdout: StdioCollector {
onStreamFinished: {
if (text.trim()) {
let devices = []
let lines = text.trim().split('\n')
let deviceType = getDeviceType(device.name || device.deviceName, device.icon)
let displayName = device.name || device.deviceName
for (let line of lines) {
if (line.trim()) {
let parts = line.split('|')
if (parts.length >= 3) {
let mac = parts[0].trim()
let name = parts[1].trim()
let connected = parts[2].trim() === 'yes'
let battery = parts[3] ? parseInt(parts[3]) : -1
// Skip if name is still a technical path
if (name.startsWith('/org/bluez') || name.includes('hci0')) {
if (!displayName || displayName.startsWith('/org/bluez') || displayName.includes('hci0') || displayName.length < 2) {
continue
}
// Determine device type from name
let type = "bluetooth"
let nameLower = name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis")) type = "headset"
else if (nameLower.includes("mouse")) type = "mouse"
else if (nameLower.includes("keyboard")) type = "keyboard"
else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
if (device.paired) {
let existingDevice = findDeviceInList(root.bluetoothDevices, device.address)
if (existingDevice) {
updateDeviceData(existingDevice, device, deviceType, displayName)
newPairedDevices.push(existingDevice)
} else {
let newDevice = createBluetoothDevice(device, deviceType, displayName)
newPairedDevices.push(newDevice)
}
} else {
if (Bluetooth.defaultAdapter.discovering && isDeviceDiscoverable(device)) {
let existingDevice = findDeviceInList(root.availableDevices, device.address)
if (existingDevice) {
updateDeviceData(existingDevice, device, deviceType, displayName)
newAvailableDevices.push(existingDevice)
} else {
let newDevice = createBluetoothDevice(device, deviceType, displayName)
newAvailableDevices.push(newDevice)
}
}
}
}
devices.push({
mac: mac,
name: name,
type: type,
connected: connected,
battery: battery
cleanupOldDevices(root.bluetoothDevices, newPairedDevices)
cleanupOldDevices(root.availableDevices, newAvailableDevices)
console.log("BluetoothService: Found", newPairedDevices.length, "paired devices and", newAvailableDevices.length, "available devices")
root.bluetoothDevices = newPairedDevices
root.availableDevices = newAvailableDevices
}
function createBluetoothDevice(nativeDevice, deviceType, displayName) {
return deviceComponent.createObject(root, {
mac: nativeDevice.address,
name: displayName,
type: deviceType,
paired: nativeDevice.paired,
connected: nativeDevice.connected,
battery: nativeDevice.batteryAvailable ? Math.round(nativeDevice.battery * 100) : -1,
signalStrength: nativeDevice.connected ? "excellent" : "unknown",
canPair: !nativeDevice.paired,
nativeDevice: nativeDevice,
connecting: false,
connectionFailed: false
})
}
}
function updateDeviceData(deviceObj, nativeDevice, deviceType, displayName) {
deviceObj.name = displayName
deviceObj.type = deviceType
deviceObj.paired = nativeDevice.paired
// If device connected state changed, clear connecting/failed states
if (deviceObj.connected !== nativeDevice.connected) {
deviceObj.connecting = false
deviceObj.connectionFailed = false
}
root.bluetoothDevices = devices
deviceObj.connected = nativeDevice.connected
deviceObj.battery = nativeDevice.batteryAvailable ? Math.round(nativeDevice.battery * 100) : -1
deviceObj.signalStrength = nativeDevice.connected ? "excellent" : "unknown"
deviceObj.canPair = !nativeDevice.paired
deviceObj.nativeDevice = nativeDevice
}
function findDeviceInList(deviceList, address) {
for (let device of deviceList) {
if (device.mac === address) {
return device
}
}
return null
}
function cleanupOldDevices(oldList, newList) {
for (let oldDevice of oldList) {
if (!newList.includes(oldDevice)) {
oldDevice.destroy()
}
}
}
function scanDevices() {
if (root.bluetoothEnabled && root.bluetoothAvailable) {
bluetoothDeviceScanner.running = true
function clearDeviceList(deviceList) {
for (let device of deviceList) {
device.destroy()
}
}
function isDeviceDiscoverable(device) {
let displayName = device.name || device.deviceName
if (!displayName || displayName.length < 2) return false
if (displayName.startsWith('/org/bluez') || displayName.includes('hci0')) return false
let nameLower = displayName.toLowerCase()
if (nameLower.match(/^[0-9a-f]{2}[:-][0-9a-f]{2}[:-][0-9a-f]{2}/)) {
return false
}
if (displayName.length < 3) return false
if (nameLower.includes('iphone') || nameLower.includes('ipad') ||
nameLower.includes('airpods') || nameLower.includes('samsung') ||
nameLower.includes('galaxy') || nameLower.includes('pixel') ||
nameLower.includes('headphone') || nameLower.includes('speaker') ||
nameLower.includes('mouse') || nameLower.includes('keyboard') ||
nameLower.includes('watch') || nameLower.includes('buds') ||
nameLower.includes('android')) {
return true
}
return displayName.length >= 4 && !displayName.match(/^[A-Z0-9_-]+$/)
}
function getDeviceType(name, icon) {
if (!name && !icon) return "bluetooth"
let nameLower = (name || "").toLowerCase()
let iconLower = (icon || "").toLowerCase()
if (iconLower.includes("audio") || iconLower.includes("headset") || iconLower.includes("headphone") ||
nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") ||
nameLower.includes("arctis") || nameLower.includes("audio")) return "headset"
else if (iconLower.includes("input-mouse") || nameLower.includes("mouse")) return "mouse"
else if (iconLower.includes("input-keyboard") || nameLower.includes("keyboard")) return "keyboard"
else if (iconLower.includes("phone") || nameLower.includes("phone") || nameLower.includes("iphone") ||
nameLower.includes("samsung") || nameLower.includes("android")) return "phone"
else if (iconLower.includes("watch") || nameLower.includes("watch")) return "watch"
else if (iconLower.includes("audio-speakers") || nameLower.includes("speaker")) return "speaker"
else if (iconLower.includes("video-display") || nameLower.includes("tv") || nameLower.includes("display")) return "tv"
return "bluetooth"
}
function startDiscovery() {
root.scanning = true
// Run comprehensive scan that gets all devices
discoveryScanner.running = true
if (Bluetooth.defaultAdapter && Bluetooth.defaultAdapter.enabled) {
Bluetooth.defaultAdapter.discovering = true
updateDevices()
}
}
function stopDiscovery() {
let stopDiscoveryProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "scan", "off"]
running: true
onExited: {
root.scanning = false
if (Bluetooth.defaultAdapter) {
Bluetooth.defaultAdapter.discovering = false
updateDevices()
}
}
', root)
}
function pairDevice(mac) {
console.log("Pairing device:", mac)
let pairProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "pair", "' + mac + '"]
running: true
onExited: (exitCode) => {
if (exitCode === 0) {
console.log("Pairing successful")
connectDevice("' + mac + '")
} else {
console.warn("Pairing failed with exit code:", exitCode)
let device = findDeviceByMac(mac)
if (device) {
device.pair()
}
availableDeviceScanner.running = true
bluetoothDeviceScanner.running = true
}
}
', root)
}
function connectDevice(mac) {
console.log("Connecting to device:", mac)
let connectProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "connect", "' + mac + '"]
running: true
onExited: (exitCode) => {
if (exitCode === 0) {
console.log("Connection successful")
} else {
console.warn("Connection failed with exit code:", exitCode)
let device = findDeviceByMac(mac)
if (device) {
device.connect()
}
bluetoothDeviceScanner.running = true
}
}
', root)
}
function removeDevice(mac) {
console.log("Removing device:", mac)
let removeProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "remove", "' + mac + '"]
running: true
onExited: {
bluetoothDeviceScanner.running = true
availableDeviceScanner.running = true
let device = findDeviceByMac(mac)
if (device) {
device.forget()
}
}
', root)
}
function toggleBluetoothDevice(mac) {
let device = root.bluetoothDevices.find(d => d.mac === mac)
if (device) {
let action = device.connected ? "disconnect" : "connect"
let toggleProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "' + action + '", "' + mac + '"]
running: true
onExited: bluetoothDeviceScanner.running = true
let typedDevice = findDeviceInList(root.bluetoothDevices, mac)
if (!typedDevice) {
typedDevice = findDeviceInList(root.availableDevices, mac)
}
if (typedDevice && typedDevice.nativeDevice) {
if (typedDevice.connected) {
console.log("Disconnecting device:", mac)
typedDevice.connecting = false
typedDevice.connectionFailed = false
typedDevice.nativeDevice.connected = false
} else {
console.log("Connecting to device:", mac)
typedDevice.connecting = true
typedDevice.connectionFailed = false
// Set a timeout to handle connection failure
Qt.callLater(() => {
connectionTimeout.deviceMac = mac
connectionTimeout.start()
})
typedDevice.nativeDevice.connected = true
}
', root)
}
}
function toggleBluetooth() {
let action = root.bluetoothEnabled ? "off" : "on"
let toggleProcess = Qt.createQmlObject('
import Quickshell.Io
Process {
command: ["bluetoothctl", "power", "' + action + '"]
running: true
onExited: bluetoothStatusChecker.running = true
if (Bluetooth.defaultAdapter) {
Bluetooth.defaultAdapter.enabled = !Bluetooth.defaultAdapter.enabled
}
', root)
}
function findDeviceByMac(mac) {
let typedDevice = findDeviceInList(root.bluetoothDevices, mac)
if (typedDevice && typedDevice.nativeDevice) {
return typedDevice.nativeDevice
}
typedDevice = findDeviceInList(root.availableDevices, mac)
if (typedDevice && typedDevice.nativeDevice) {
return typedDevice.nativeDevice
}
if (Bluetooth.defaultAdapter) {
let adapterDevices = Bluetooth.defaultAdapter.devices
if (adapterDevices.values) {
for (let device of adapterDevices.values) {
if (device && device.address === mac) {
return device
}
}
}
}
if (Bluetooth.devices.values) {
for (let device of Bluetooth.devices.values) {
if (device && device.address === mac) {
return device
}
}
}
return null
}
Timer {
id: bluetoothMonitorTimer
interval: 5000
running: false; repeat: true
interval: 2000
running: false
repeat: true
onTriggered: {
bluetoothStatusChecker.running = true
if (root.bluetoothEnabled) {
bluetoothDeviceScanner.running = true
// Also refresh paired devices to get current connection status
pairedDeviceChecker.discoveredToMerge = []
pairedDeviceChecker.running = true
}
updateDevices()
}
}
function enableMonitoring(enabled) {
bluetoothMonitorTimer.running = enabled
if (enabled) {
// Immediately update when enabled
bluetoothStatusChecker.running = true
refreshBluetoothState()
updateDevices()
}
}
property var discoveredDevices: []
// Handle discovered devices
function _handleDiscovered(found) {
let discoveredDevices = []
for (let device of found) {
let type = "bluetooth"
let nameLower = device.name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset"
else if (nameLower.includes("mouse")) type = "mouse"
else if (nameLower.includes("keyboard")) type = "keyboard"
else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung") || nameLower.includes("android")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv"
discoveredDevices.push({
mac: device.mac,
name: device.name,
type: type,
paired: false,
connected: false,
rssi: -70,
signalStrength: "fair",
canPair: true
})
console.log(" -", device.name, "(", device.mac, ")")
Timer {
id: bluetoothStateRefreshTimer
interval: 5000
running: true
repeat: true
onTriggered: {
refreshBluetoothState()
}
}
// Get paired devices first, then merge with discovered
pairedDeviceChecker.discoveredToMerge = discoveredDevices
pairedDeviceChecker.running = true
}
// Get only currently connected/paired devices that matter
Process {
id: availableDeviceScanner
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([A-F0-9:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; info=$(bluetoothctl info \"$mac\" 2>/dev/null); paired=$(echo \"$info\" | grep -m1 'Paired:' | awk '{print $2}'); connected=$(echo \"$info\" | grep -m1 'Connected:' | awk '{print $2}'); if [[ \"$paired\" == \"yes\" ]] || [[ \"$connected\" == \"yes\" ]]; then echo \"$mac|$name|$paired|$connected\"; fi; fi; done"]
Timer {
id: connectionTimeout
interval: 10000 // 10 second timeout
running: false
repeat: false
stdout: StdioCollector {
onStreamFinished: {
property string deviceMac: ""
let devices = []
if (text.trim()) {
let lines = text.trim().split('\n')
for (let line of lines) {
if (line.trim()) {
let parts = line.split('|')
if (parts.length >= 4) {
let mac = parts[0].trim()
let name = parts[1].trim()
let paired = parts[2].trim() === 'yes'
let connected = parts[3].trim() === 'yes'
// Skip technical names
if (name.startsWith('/org/bluez') || name.includes('hci0') || name.length < 3) {
continue
onTriggered: {
if (deviceMac) {
let typedDevice = findDeviceInList(root.bluetoothDevices, deviceMac)
if (!typedDevice) {
typedDevice = findDeviceInList(root.availableDevices, deviceMac)
}
// Determine device type
let type = "bluetooth"
let nameLower = name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset"
else if (nameLower.includes("mouse")) type = "mouse"
else if (nameLower.includes("keyboard")) type = "keyboard"
else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung") || nameLower.includes("android")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv"
if (typedDevice && typedDevice.connecting && !typedDevice.connected) {
console.log("Connection timeout for device:", deviceMac)
typedDevice.connecting = false
typedDevice.connectionFailed = true
devices.push({
mac: mac,
name: name,
type: type,
paired: paired,
connected: connected,
rssi: 0,
signalStrength: "unknown",
canPair: false // Already paired
// Clear failure state after 3 seconds
Qt.callLater(() => {
clearFailureTimer.deviceMac = deviceMac
clearFailureTimer.start()
})
}
deviceMac = ""
}
}
}
root.availableDevices = devices
}
}
}
// Discovery scanner using bluetoothctl --timeout
Process {
id: discoveryScanner
// Discover for 8 s in non-interactive mode, then auto-exit
command: ["bluetoothctl",
"--timeout", "8",
"--monitor", // keeps stdout unbuffered
"scan", "on"]
Timer {
id: clearFailureTimer
interval: 3000
running: false
repeat: false
stdout: StdioCollector {
onStreamFinished: {
/*
* bluetoothctl prints lines like:
* [NEW] Device 12:34:56:78:9A:BC My-Headphones
*/
const rx = /^\[NEW\] Device ([0-9A-F:]+)\s+(.+)$/i;
const found = text.split('\n')
.filter(l => rx.test(l))
.map(l => {
const [,mac,name] = l.match(rx);
return { mac, name };
});
root._handleDiscovered(found);
}
property string deviceMac: ""
onTriggered: {
if (deviceMac) {
let typedDevice = findDeviceInList(root.bluetoothDevices, deviceMac)
if (!typedDevice) {
typedDevice = findDeviceInList(root.availableDevices, deviceMac)
}
onExited: {
root.scanning = false
}
}
// Get paired devices and merge with discovered ones
Process {
id: pairedDeviceChecker
command: ["bash", "-c", "bluetoothctl devices | while read -r line; do if [[ $line =~ Device\\ ([A-F0-9:]+)\\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ${#name} -gt 3 ]] && [[ ! $name =~ ^/org/bluez ]] && [[ ! $name =~ hci0 ]]; then info=$(bluetoothctl info \"$mac\" 2>/dev/null); paired=$(echo \"$info\" | grep -m1 'Paired:' | awk '{print $2}'); connected=$(echo \"$info\" | grep -m1 'Connected:' | awk '{print $2}'); echo \"$mac|$name|$paired|$connected\"; fi; fi; done"]
running: false
property var discoveredToMerge: []
stdout: StdioCollector {
onStreamFinished: {
// Start with discovered devices (unpaired, available to pair)
let allDevices = [...pairedDeviceChecker.discoveredToMerge]
let seenMacs = new Set(allDevices.map(d => d.mac))
// Add only actually paired devices from bluetoothctl
if (text.trim()) {
let lines = text.trim().split('\n')
for (let line of lines) {
if (line.trim()) {
let parts = line.split('|')
if (parts.length >= 4) {
let mac = parts[0].trim()
let name = parts[1].trim()
let paired = parts[2].trim() === 'yes'
let connected = parts[3].trim() === 'yes'
// Only include if actually paired
if (!paired) continue
// Check if already in discovered list
if (seenMacs.has(mac)) {
// Update existing device to show it's paired
let existing = allDevices.find(d => d.mac === mac)
if (existing) {
existing.paired = true
existing.connected = connected
existing.canPair = false
}
continue
}
// Add paired device not found during scan
let type = "bluetooth"
let nameLower = name.toLowerCase()
if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset"
else if (nameLower.includes("mouse")) type = "mouse"
else if (nameLower.includes("keyboard")) type = "keyboard"
else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung") || nameLower.includes("android")) type = "phone"
else if (nameLower.includes("watch")) type = "watch"
else if (nameLower.includes("speaker")) type = "speaker"
else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv"
allDevices.push({
mac: mac,
name: name,
type: type,
paired: true,
connected: connected,
rssi: -100,
signalStrength: "unknown",
canPair: false
})
if (typedDevice) {
typedDevice.connectionFailed = false
}
deviceMac = ""
}
}
}
root.availableDevices = allDevices
root.scanning = false
component BluetoothDevice: QtObject {
required property string mac
required property string name
required property string type
required property bool paired
required property bool connected
required property int battery
required property string signalStrength
required property bool canPair
required property var nativeDevice // Reference to native Quickshell device
property bool connecting: false
property bool connectionFailed: false
readonly property string displayName: name
readonly property bool batteryAvailable: battery >= 0
readonly property string connectionStatus: {
if (connecting) return "Connecting..."
if (connectionFailed) return "Connection Failed"
if (connected) return "Connected"
return "Disconnected"
}
}
Component {
id: deviceComponent
BluetoothDevice {}
}
}

View File

@@ -7,15 +7,12 @@ pragma ComponentBehavior: Bound
Singleton {
id: root
// Process list properties
property var processes: []
property bool isUpdating: false
property int processUpdateInterval: 3000
// Performance control - only run when process monitor is actually visible
property bool monitoringEnabled: false
// System information properties
property int totalMemoryKB: 0
property int usedMemoryKB: 0
property int totalSwapKB: 0
@@ -24,28 +21,23 @@ Singleton {
property real totalCpuUsage: 0.0
property bool systemInfoAvailable: false
// Performance history for charts
property var cpuHistory: []
property var memoryHistory: []
property var networkHistory: ({rx: [], tx: []})
property var diskHistory: ({read: [], write: []})
property int historySize: 60 // Keep 60 data points
property int historySize: 60
// Per-core CPU usage
property var perCoreCpuUsage: []
// Network stats
property real networkRxRate: 0 // bytes/sec
property real networkTxRate: 0 // bytes/sec
property real networkRxRate: 0
property real networkTxRate: 0
property var lastNetworkStats: null
// Disk I/O stats
property real diskReadRate: 0 // bytes/sec
property real diskWriteRate: 0 // bytes/sec
property real diskReadRate: 0
property real diskWriteRate: 0
property var lastDiskStats: null
// Sorting options
property string sortBy: "cpu" // "cpu", "memory", "name", "pid"
property string sortBy: "cpu"
property bool sortDescending: true
property int maxProcesses: 20
@@ -53,9 +45,6 @@ Singleton {
console.log("ProcessMonitorService: Starting initialization...")
updateProcessList()
console.log("ProcessMonitorService: Initialization complete")
// Test monitoring disabled - only monitor when explicitly enabled
// testTimer.start()
}
Timer {
@@ -66,7 +55,6 @@ Singleton {
onTriggered: {
console.log("ProcessMonitorService: Starting test monitoring...")
enableMonitoring(true)
// Stop after 8 seconds
stopTestTimer.start()
}
}
@@ -82,7 +70,6 @@ Singleton {
}
}
// System information monitoring
Process {
id: systemInfoProcess
command: ["bash", "-c", "cat /proc/meminfo; echo '---CPU---'; nproc; echo '---CPUSTAT---'; grep '^cpu' /proc/stat | head -" + (root.cpuCount + 1)]
@@ -104,7 +91,6 @@ Singleton {
}
}
// Network monitoring process
Process {
id: networkStatsProcess
command: ["bash", "-c", "cat /proc/net/dev | grep -E '(wlan|eth|enp|wlp|ens|eno)' | awk '{print $1,$2,$10}' | sed 's/:/ /'"]
@@ -119,7 +105,6 @@ Singleton {
}
}
// Disk I/O monitoring process
Process {
id: diskStatsProcess
command: ["bash", "-c", "cat /proc/diskstats | grep -E ' (sd[a-z]+|nvme[0-9]+n[0-9]+|vd[a-z]+) ' | grep -v 'p[0-9]' | awk '{print $3,$6,$10}'"]
@@ -134,7 +119,6 @@ Singleton {
}
}
// Process monitoring with ps command
Process {
id: processListProcess
command: ["bash", "-c", "ps axo pid,ppid,pcpu,pmem,rss,comm,cmd --sort=-pcpu | head -" + (root.maxProcesses + 1)]
@@ -146,12 +130,10 @@ Singleton {
const lines = text.trim().split('\n')
const newProcesses = []
// Skip header line
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
// Parse ps output: PID PPID %CPU %MEM RSS COMMAND CMD
const parts = line.split(/\s+/)
if (parts.length >= 7) {
const pid = parseInt(parts[0])
@@ -189,11 +171,10 @@ Singleton {
}
}
// System and process monitoring timer - now conditional
Timer {
id: processTimer
interval: root.processUpdateInterval
running: root.monitoringEnabled // Only run when monitoring is enabled
running: root.monitoringEnabled
repeat: true
onTriggered: {
@@ -206,29 +187,24 @@ Singleton {
}
}
// Public functions
function updateSystemInfo() {
if (!systemInfoProcess.running && root.monitoringEnabled) {
systemInfoProcess.running = true
}
}
// Control functions for enabling/disabling monitoring
function enableMonitoring(enabled) {
console.log("ProcessMonitorService: Monitoring", enabled ? "enabled" : "disabled")
root.monitoringEnabled = enabled
if (enabled) {
// Clear history when starting
root.cpuHistory = []
root.memoryHistory = []
root.networkHistory = ({rx: [], tx: []})
root.diskHistory = ({read: [], write: []})
// Immediately update when enabled
updateSystemInfo()
updateProcessList()
updateNetworkStats()
updateDiskStats()
// console.log("ProcessMonitorService: Initial data collection started")
}
}
@@ -248,7 +224,6 @@ Singleton {
if (!root.isUpdating && root.monitoringEnabled) {
root.isUpdating = true
// Update sort command based on current sort option
let sortOption = ""
switch (root.sortBy) {
case "cpu":
@@ -307,7 +282,6 @@ Singleton {
}
function getProcessIcon(command) {
// Return appropriate Material Design icon for common processes
const cmd = command.toLowerCase()
if (cmd.includes("firefox") || cmd.includes("chrome") || cmd.includes("browser")) return "web"
if (cmd.includes("code") || cmd.includes("editor") || cmd.includes("vim")) return "code"
@@ -315,7 +289,7 @@ Singleton {
if (cmd.includes("music") || cmd.includes("audio") || cmd.includes("spotify")) return "music_note"
if (cmd.includes("video") || cmd.includes("vlc") || cmd.includes("mpv")) return "play_circle"
if (cmd.includes("systemd") || cmd.includes("kernel") || cmd.includes("kthread")) return "settings"
return "memory" // Default process icon
return "memory"
}
function formatCpuUsage(cpu) {
@@ -444,7 +418,6 @@ Singleton {
root.networkRxRate = Math.max(0, (totalRx - root.lastNetworkStats.rx) / timeDiff)
root.networkTxRate = Math.max(0, (totalTx - root.lastNetworkStats.tx) / timeDiff)
// Convert to KB/s for history
addToHistory(root.networkHistory.rx, root.networkRxRate / 1024)
addToHistory(root.networkHistory.tx, root.networkTxRate / 1024)
}
@@ -463,7 +436,7 @@ Singleton {
const readSectors = parseInt(parts[1])
const writeSectors = parseInt(parts[2])
if (!isNaN(readSectors) && !isNaN(writeSectors)) {
totalRead += readSectors * 512 // Convert sectors to bytes
totalRead += readSectors * 512
totalWrite += writeSectors * 512
}
}
@@ -474,7 +447,6 @@ Singleton {
root.diskReadRate = Math.max(0, (totalRead - root.lastDiskStats.read) / timeDiff)
root.diskWriteRate = Math.max(0, (totalWrite - root.lastDiskStats.write) / timeDiff)
// Convert to MB/s for history
addToHistory(root.diskHistory.read, root.diskReadRate / (1024 * 1024))
addToHistory(root.diskHistory.write, root.diskWriteRate / (1024 * 1024))
}

View File

@@ -7,16 +7,13 @@ pragma ComponentBehavior: Bound
Singleton {
id: root
// CPU properties
property real cpuUsage: 0.0
property int cpuCores: 1
property string cpuModel: ""
property real cpuFrequency: 0.0
// Previous CPU stats for accurate calculation
property var prevCpuStats: [0, 0, 0, 0, 0, 0, 0, 0]
// Memory properties
property real memoryUsage: 0.0
property real totalMemory: 0.0
property real usedMemory: 0.0
@@ -25,14 +22,12 @@ Singleton {
property real bufferMemory: 0.0
property real cacheMemory: 0.0
// Temperature properties
property real cpuTemperature: 0.0
property int cpuUpdateInterval: 3000
property int memoryUpdateInterval: 5000
property int temperatureUpdateInterval: 10000
// Performance control
property bool enabledForTopBar: true
property bool enabledForDetailedView: false
@@ -43,7 +38,6 @@ Singleton {
console.log("SystemMonitorService: Initialization complete")
}
// Get CPU information (static)
Process {
id: cpuInfoProcess
command: ["bash", "-c", "lscpu | grep -E 'Model name|CPU\\(s\\):' | head -2"]
@@ -69,7 +63,6 @@ Singleton {
}
}
// CPU usage monitoring with accurate calculation
Process {
id: cpuUsageProcess
command: ["bash", "-c", "head -1 /proc/stat | awk '{print $2,$3,$4,$5,$6,$7,$8,$9}'"]
@@ -80,17 +73,14 @@ Singleton {
if (text.trim()) {
const stats = text.trim().split(" ").map(x => parseInt(x))
if (root.prevCpuStats[0] > 0) {
// Calculate differences
let diffs = []
for (let i = 0; i < 8; i++) {
diffs[i] = stats[i] - root.prevCpuStats[i]
}
// Calculate total and idle time
const totalTime = diffs.reduce((a, b) => a + b, 0)
const idleTime = diffs[3] + diffs[4] // idle + iowait
const idleTime = diffs[3] + diffs[4]
// CPU usage percentage
if (totalTime > 0) {
root.cpuUsage = Math.max(0, Math.min(100, ((totalTime - idleTime) / totalTime) * 100))
}
@@ -107,7 +97,6 @@ Singleton {
}
}
// Memory usage monitoring
Process {
id: memoryUsageProcess
command: ["bash", "-c", "free -m | awk 'NR==2{printf \"%.1f %.1f %.1f %.1f\", $3*100/$2, $2, $3, $7}'"]
@@ -133,7 +122,6 @@ Singleton {
}
}
// CPU frequency monitoring
Process {
id: cpuFrequencyProcess
command: ["bash", "-c", "cat /proc/cpuinfo | grep 'cpu MHz' | head -1 | awk '{print $4}'"]
@@ -154,7 +142,6 @@ Singleton {
}
}
// CPU temperature monitoring
Process {
id: temperatureProcess
command: ["bash", "-c", "if [ -f /sys/class/thermal/thermal_zone0/temp ]; then cat /sys/class/thermal/thermal_zone0/temp | awk '{print $1/1000}'; else sensors 2>/dev/null | grep 'Core 0' | awk '{print $3}' | sed 's/+//g;s/°C//g' | head -1; fi"]
@@ -175,7 +162,6 @@ Singleton {
}
}
// CPU monitoring timer
Timer {
id: cpuTimer
interval: root.cpuUpdateInterval
@@ -190,7 +176,6 @@ Singleton {
}
}
// Memory monitoring timer
Timer {
id: memoryTimer
interval: root.memoryUpdateInterval
@@ -204,7 +189,6 @@ Singleton {
}
}
// Temperature monitoring timer
Timer {
id: temperatureTimer
interval: root.temperatureUpdateInterval
@@ -218,7 +202,6 @@ Singleton {
}
}
// Public functions
function getCpuInfo() {
cpuInfoProcess.running = true
}
@@ -243,15 +226,15 @@ Singleton {
}
function getCpuUsageColor() {
if (cpuUsage > 80) return "#e74c3c" // Red
if (cpuUsage > 60) return "#f39c12" // Orange
return "#27ae60" // Green
if (cpuUsage > 80) return "#e74c3c"
if (cpuUsage > 60) return "#f39c12"
return "#27ae60"
}
function getMemoryUsageColor() {
if (memoryUsage > 90) return "#e74c3c" // Red
if (memoryUsage > 75) return "#f39c12" // Orange
return "#3498db" // Blue
if (memoryUsage > 90) return "#e74c3c"
if (memoryUsage > 75) return "#f39c12"
return "#3498db"
}
function formatMemory(mb) {
@@ -262,8 +245,8 @@ Singleton {
}
function getTemperatureColor() {
if (cpuTemperature > 80) return "#e74c3c" // Red
if (cpuTemperature > 65) return "#f39c12" // Orange
return "#27ae60" // Green
if (cpuTemperature > 80) return "#e74c3c"
if (cpuTemperature > 65) return "#f39c12"
return "#27ae60"
}
}

View File

@@ -6,19 +6,23 @@ import Quickshell.Io
import "../../Common"
import "../../Services"
ScrollView {
Item {
id: bluetoothTab
clip: true
// These should be bound from parent
property bool bluetoothEnabled: false
property var bluetoothDevices: []
ScrollView {
anchors.fill: parent
clip: true
ScrollBar.vertical.policy: ScrollBar.AsNeeded
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
Column {
width: parent.width
spacing: Theme.spacingL
// Bluetooth toggle
Rectangle {
width: parent.width
height: 60
@@ -73,7 +77,6 @@ ScrollView {
}
}
// Bluetooth devices (when enabled)
Column {
width: parent.width
spacing: Theme.spacingM
@@ -86,7 +89,6 @@ ScrollView {
font.weight: Font.Medium
}
// Real Bluetooth devices
Repeater {
model: bluetoothTab.bluetoothDevices
@@ -117,8 +119,22 @@ ScrollView {
}
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize
color: modelData.connected ? Theme.primary : Theme.surfaceText
color: {
if (modelData.connecting) return Theme.primary
if (modelData.connected) return Theme.primary
return Theme.surfaceText
}
anchors.verticalCenter: parent.verticalCenter
opacity: modelData.connecting ? 0.6 : 1.0
Behavior on opacity {
SequentialAnimation {
running: modelData.connecting
loops: Animation.Infinite
NumberAnimation { from: 1.0; to: 0.3; duration: 800 }
NumberAnimation { from: 0.3; to: 1.0; duration: 800 }
}
}
}
Column {
@@ -136,9 +152,13 @@ ScrollView {
spacing: Theme.spacingXS
Text {
text: modelData.connected ? "Connected" : "Disconnected"
text: modelData.connectionStatus
font.pixelSize: Theme.fontSizeSmall
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
color: {
if (modelData.connecting) return Theme.primary
if (modelData.connectionFailed) return Theme.error
return Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
}
}
Text {
@@ -151,21 +171,67 @@ ScrollView {
}
}
Rectangle {
id: btMenuButton
width: 32
height: 32
radius: Theme.cornerRadius
color: btMenuButtonArea.containsMouse ?
Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) :
"transparent"
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
Text {
text: "more_vert"
font.family: Theme.iconFont
font.weight: Theme.iconFontWeight
font.pixelSize: Theme.iconSize
color: Theme.surfaceText
opacity: 0.6
anchors.centerIn: parent
}
MouseArea {
id: btMenuButtonArea
anchors.fill: parent
hoverEnabled: !modelData.connecting
enabled: !modelData.connecting
cursorShape: modelData.connecting ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: {
if (!modelData.connecting) {
bluetoothContextMenuWindow.deviceData = modelData
let localPos = btMenuButtonArea.mapToItem(bluetoothTab, btMenuButtonArea.width / 2, btMenuButtonArea.height)
bluetoothContextMenuWindow.show(localPos.x, localPos.y)
}
}
}
Behavior on color {
ColorAnimation { duration: Theme.shortDuration }
}
}
MouseArea {
id: btDeviceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
anchors.rightMargin: 40 // Don't overlap with menu button
hoverEnabled: !modelData.connecting
enabled: !modelData.connecting
cursorShape: modelData.connecting ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: {
if (!modelData.connecting) {
BluetoothService.toggleBluetoothDevice(modelData.mac)
}
}
}
}
}
}
// Available devices for pairing (when enabled)
Column {
width: parent.width
spacing: Theme.spacingM
@@ -186,8 +252,8 @@ ScrollView {
Item { width: 1; height: 1 }
Rectangle {
width: Math.max(100, scanText.contentWidth + Theme.spacingM * 2)
height: 32
width: Math.max(140, scanText.contentWidth + Theme.spacingL * 2)
height: 36
radius: Theme.cornerRadius
color: scanArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
border.color: Theme.primary
@@ -198,24 +264,16 @@ ScrollView {
spacing: Theme.spacingXS
Text {
text: BluetoothService.scanning ? "search" : "bluetooth_searching"
text: BluetoothService.scanning ? "stop" : "bluetooth_searching"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 4
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
running: BluetoothService.scanning
loops: Animation.Infinite
from: 0
to: 360
duration: 2000
}
}
Text {
id: scanText
text: BluetoothService.scanning ? "Scanning..." : "Scan"
text: BluetoothService.scanning ? "Stop Scanning" : "Start Scanning"
font.pixelSize: Theme.fontSizeMedium
color: Theme.primary
font.weight: Font.Medium
@@ -228,16 +286,18 @@ ScrollView {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
enabled: !BluetoothService.scanning
onClicked: {
if (BluetoothService.scanning) {
BluetoothService.stopDiscovery()
} else {
BluetoothService.startDiscovery()
}
}
}
}
}
// Available devices list
Repeater {
model: BluetoothService.availableDevices
@@ -309,7 +369,6 @@ ScrollView {
}
}
// Action button on the right
Rectangle {
width: 80
height: 28
@@ -368,9 +427,50 @@ ScrollView {
}
}
// No devices message
Column {
width: parent.width
spacing: Theme.spacingM
visible: BluetoothService.scanning && BluetoothService.availableDevices.length === 0
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingM
Text {
text: "No devices found. Put your device in pairing mode and click Scan."
text: "sync"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSizeLarge
color: Theme.primary
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
running: true
loops: Animation.Infinite
from: 0
to: 360
duration: 2000
}
}
Text {
text: "Scanning for devices..."
font.pixelSize: Theme.fontSizeLarge
color: Theme.surfaceText
font.weight: Font.Medium
anchors.verticalCenter: parent.verticalCenter
}
}
Text {
text: "Make sure your device is in pairing mode"
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
anchors.horizontalCenter: parent.horizontalCenter
}
}
Text {
text: "No devices found. Put your device in pairing mode and click Start Scanning."
font.pixelSize: Theme.fontSizeMedium
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
visible: BluetoothService.availableDevices.length === 0 && !BluetoothService.scanning
@@ -380,4 +480,211 @@ ScrollView {
}
}
}
}
Rectangle {
id: bluetoothContextMenuWindow
property var deviceData: null
property bool menuVisible: false
visible: false
width: 160
height: menuColumn.implicitHeight + Theme.spacingS * 2
radius: Theme.cornerRadiusLarge
color: Theme.popupBackground()
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: 1
z: 1000
Rectangle {
anchors.fill: parent
anchors.topMargin: 4
anchors.leftMargin: 2
anchors.rightMargin: -2
anchors.bottomMargin: -4
radius: parent.radius
color: Qt.rgba(0, 0, 0, 0.15)
z: parent.z - 1
}
opacity: menuVisible ? 1.0 : 0.0
scale: menuVisible ? 1.0 : 0.85
Behavior on opacity {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Behavior on scale {
NumberAnimation {
duration: Theme.mediumDuration
easing.type: Theme.emphasizedEasing
}
}
Column {
id: menuColumn
anchors.fill: parent
anchors.margins: Theme.spacingS
spacing: 1
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadiusSmall
color: connectArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent"
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
Text {
text: bluetoothContextMenuWindow.deviceData && bluetoothContextMenuWindow.deviceData.connected ? "link_off" : "link"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 2
color: Theme.surfaceText
opacity: 0.7
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: bluetoothContextMenuWindow.deviceData && bluetoothContextMenuWindow.deviceData.connected ? "Disconnect" : "Connect"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: connectArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (bluetoothContextMenuWindow.deviceData) {
BluetoothService.toggleBluetoothDevice(bluetoothContextMenuWindow.deviceData.mac)
}
bluetoothContextMenuWindow.hide()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Rectangle {
width: parent.width - Theme.spacingS * 2
height: 5
anchors.horizontalCenter: parent.horizontalCenter
color: "transparent"
Rectangle {
anchors.centerIn: parent
width: parent.width
height: 1
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
}
}
Rectangle {
width: parent.width
height: 32
radius: Theme.cornerRadiusSmall
color: forgetArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
Row {
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
Text {
text: "delete"
font.family: Theme.iconFont
font.pixelSize: Theme.iconSize - 2
color: forgetArea.containsMouse ? Theme.error : Theme.surfaceText
opacity: 0.7
anchors.verticalCenter: parent.verticalCenter
}
Text {
text: "Forget Device"
font.pixelSize: Theme.fontSizeSmall
color: forgetArea.containsMouse ? Theme.error : Theme.surfaceText
font.weight: Font.Normal
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: forgetArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
if (bluetoothContextMenuWindow.deviceData) {
BluetoothService.removeDevice(bluetoothContextMenuWindow.deviceData.mac)
}
bluetoothContextMenuWindow.hide()
}
}
Behavior on color {
ColorAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
}
function show(x, y) {
const menuWidth = 160
const menuHeight = menuColumn.implicitHeight + Theme.spacingS * 2
let finalX = x - menuWidth / 2
let finalY = y
finalX = Math.max(0, Math.min(finalX, bluetoothTab.width - menuWidth))
finalY = Math.max(0, Math.min(finalY, bluetoothTab.height - menuHeight))
bluetoothContextMenuWindow.x = finalX
bluetoothContextMenuWindow.y = finalY
bluetoothContextMenuWindow.visible = true
bluetoothContextMenuWindow.menuVisible = true
}
function hide() {
bluetoothContextMenuWindow.menuVisible = false
Qt.callLater(() => { bluetoothContextMenuWindow.visible = false })
}
}
MouseArea {
anchors.fill: parent
visible: bluetoothContextMenuWindow.visible
onClicked: {
bluetoothContextMenuWindow.hide()
}
MouseArea {
x: bluetoothContextMenuWindow.x
y: bluetoothContextMenuWindow.y
width: bluetoothContextMenuWindow.width
height: bluetoothContextMenuWindow.height
onClicked: {
}
}
}
}

View File

@@ -334,7 +334,7 @@ PanelWindow {
topBar.shellRoot.controlCenterVisible = !topBar.shellRoot.controlCenterVisible
if (topBar.shellRoot.controlCenterVisible) {
WifiService.scanWifi()
BluetoothService.scanDevices()
// Bluetooth devices are automatically updated via signals
}
}
}