From f75ff03281cdfd8cea92cdcb1b0ebd9fe46c08b0 Mon Sep 17 00:00:00 2001 From: ly-sec Date: Sun, 20 Jul 2025 09:25:58 +0200 Subject: [PATCH] Add Audio Input/Output selector, add (probably) working battery indicator --- Bar/Bar.qml | 5 + Bar/Modules/AudioDeviceSelector.qml | 283 ++++++++++++++++++++++++++++ Bar/Modules/Battery.qml | 81 ++++++++ Bar/Modules/Calendar.qml | 2 +- Bar/Modules/Volume.qml | 27 ++- 5 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 Bar/Modules/AudioDeviceSelector.qml create mode 100644 Bar/Modules/Battery.qml diff --git a/Bar/Bar.qml b/Bar/Bar.qml index f2fb661..83c3ae7 100644 --- a/Bar/Bar.qml +++ b/Bar/Bar.qml @@ -87,6 +87,11 @@ Scope { anchors.verticalCenter: parent.verticalCenter } + Battery { + id: widgetsBattery + anchors.verticalCenter: parent.verticalCenter + } + Brightness { id: widgetsBrightness anchors.verticalCenter: parent.verticalCenter diff --git a/Bar/Modules/AudioDeviceSelector.qml b/Bar/Modules/AudioDeviceSelector.qml new file mode 100644 index 0000000..79ed497 --- /dev/null +++ b/Bar/Modules/AudioDeviceSelector.qml @@ -0,0 +1,283 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell.Services.Pipewire +import qs.Components +import qs.Settings + +PanelWithOverlay { + id: ioSelector + signal closed() + property int tabIndex: 0 + property Item anchorItem: null + + // Bind all Pipewire nodes so their properties are valid + PwObjectTracker { + id: nodeTracker + objects: Pipewire.nodes + } + + Rectangle { + color: Theme.backgroundPrimary + radius: 20 + width: 340 + height: 340 + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 4 + anchors.rightMargin: 4 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 10 + + // Tabs centered inside the window + RowLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignHCenter + spacing: 0 + + Tabs { + id: ioTabs + tabsModel: [ + { label: "Output", icon: "volume_up" }, + { label: "Input", icon: "mic" } + ] + currentIndex: tabIndex + onTabChanged: { + tabIndex = currentIndex; + } + } + } + + // Add vertical space between tabs and entries + Item { height: 36; Layout.fillWidth: true } + + // Output Devices + Flickable { + id: sinkList + visible: tabIndex === 0 + contentHeight: sinkColumn.height + clip: true + interactive: contentHeight > height + width: parent.width + height: 220 + ScrollBar.vertical: ScrollBar {} + ColumnLayout { + id: sinkColumn + width: sinkList.width + spacing: 6 + Repeater { + model: ioSelector.sinkNodes() + Rectangle { + width: parent.width + height: 36 + color: "transparent" + radius: 6 + RowLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 8 + Text { + text: "volume_up" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary + Layout.alignment: Qt.AlignVCenter + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + text: modelData.nickname || modelData.description || modelData.name + font.bold: true + font.pixelSize: 12 + color: (Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary + elide: Text.ElideRight + } + Text { + text: modelData.description !== modelData.nickname ? modelData.description : "" + font.pixelSize: 10 + color: Theme.textSecondary + elide: Text.ElideRight + } + } + Item { Layout.fillWidth: true } + Rectangle { + visible: Pipewire.preferredDefaultAudioSink !== modelData + width: 60; height: 20 + radius: 4 + color: Theme.accentPrimary + border.color: Theme.accentPrimary + border.width: 1 + Layout.alignment: Qt.AlignVCenter + Text { + anchors.centerIn: parent + text: "Set" + color: Theme.onAccent + font.pixelSize: 10 + font.bold: true + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Pipewire.preferredDefaultAudioSink = modelData + } + } + Text { + text: "(Current)" + visible: Pipewire.defaultAudioSink && Pipewire.defaultAudioSink.id === modelData.id + color: Theme.accentPrimary + font.pixelSize: 10 + Layout.alignment: Qt.AlignVCenter + } + } + } + } + } + } + + // Input Devices + Flickable { + id: sourceList + visible: tabIndex === 1 + contentHeight: sourceColumn.height + clip: true + interactive: contentHeight > height + width: parent.width + height: 220 + ScrollBar.vertical: ScrollBar {} + ColumnLayout { + id: sourceColumn + width: sourceList.width + spacing: 6 + Repeater { + model: ioSelector.sourceNodes() + Rectangle { + width: parent.width + height: 36 + color: "transparent" + radius: 6 + RowLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 8 + Text { + text: "mic" + font.family: "Material Symbols Outlined" + font.pixelSize: 16 + color: (Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary + Layout.alignment: Qt.AlignVCenter + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + text: modelData.nickname || modelData.description || modelData.name + font.bold: true + font.pixelSize: 12 + color: (Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id) ? Theme.accentPrimary : Theme.textPrimary + elide: Text.ElideRight + } + Text { + text: modelData.description !== modelData.nickname ? modelData.description : "" + font.pixelSize: 10 + color: Theme.textSecondary + elide: Text.ElideRight + } + } + Item { Layout.fillWidth: true } + Rectangle { + visible: Pipewire.preferredDefaultAudioSource !== modelData + width: 60; height: 20 + radius: 4 + color: Theme.accentPrimary + border.color: Theme.accentPrimary + border.width: 1 + Layout.alignment: Qt.AlignVCenter + Text { + anchors.centerIn: parent + text: "Set" + color: Theme.onAccent + font.pixelSize: 10 + font.bold: true + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: Pipewire.preferredDefaultAudioSource = modelData + } + } + Text { + text: "(Current)" + visible: Pipewire.defaultAudioSource && Pipewire.defaultAudioSource.id === modelData.id + color: Theme.accentPrimary + font.pixelSize: 10 + Layout.alignment: Qt.AlignVCenter + } + } + } + } + } + } + } + } + + function sinkNodes() { + let nodes = Pipewire.nodes && Pipewire.nodes.values + ? Pipewire.nodes.values.filter(function(n) { return n.isSink && n.audio }) + : []; + if (Pipewire.defaultAudioSink) { + nodes = nodes.slice().sort(function(a, b) { + if (a.id === Pipewire.defaultAudioSink.id) return -1; + if (b.id === Pipewire.defaultAudioSink.id) return 1; + return 0; + }); + } + return nodes; + } + function sourceNodes() { + let nodes = Pipewire.nodes && Pipewire.nodes.values + ? Pipewire.nodes.values.filter(function(n) { return !n.isSink && n.audio }) + : []; + if (Pipewire.defaultAudioSource) { + nodes = nodes.slice().sort(function(a, b) { + if (a.id === Pipewire.defaultAudioSource.id) return -1; + if (b.id === Pipewire.defaultAudioSource.id) return 1; + return 0; + }); + } + return nodes; + } + + Component.onCompleted: { + if (Pipewire.nodes && Pipewire.nodes.values) { + for (var i = 0; i < Pipewire.nodes.values.length; ++i) { + var n = Pipewire.nodes.values[i]; + } + } + } + + Connections { + target: Pipewire + function onReadyChanged() { + if (Pipewire.ready && Pipewire.nodes && Pipewire.nodes.values) { + for (var i = 0; i < Pipewire.nodes.values.length; ++i) { + var n = Pipewire.nodes.values[i]; + } + } + } + function onDefaultAudioSinkChanged() { + } + function onDefaultAudioSourceChanged() { + } + } + + Component.onDestruction: { + } + onVisibleChanged: { + if (visible) { + } + } +} \ No newline at end of file diff --git a/Bar/Modules/Battery.qml b/Bar/Modules/Battery.qml new file mode 100644 index 0000000..49b3eb3 --- /dev/null +++ b/Bar/Modules/Battery.qml @@ -0,0 +1,81 @@ +import QtQuick +import Quickshell.Services.UPower +import QtQuick.Layouts +import qs.Settings +import qs.Components + +Item { + id: batteryWidget + property var battery: UPower.displayDevice + property bool isReady: battery && battery.ready && battery.isLaptopBattery && battery.isPresent + property real percent: isReady ? battery.percentage : 0 + property bool charging: isReady ? battery.state === UPowerDeviceState.Charging : false + property bool show: isReady && percent > 0 + + // Choose icon based on charge and charging state + function batteryIcon() { + if (!show) return ""; + if (percent >= 95) return "battery_full"; + if (percent >= 80) return "battery_80"; + if (percent >= 60) return "battery_60"; + if (percent >= 50) return "battery_50"; + if (percent >= 30) return "battery_30"; + if (percent >= 20) return "battery_20"; + return "battery_alert"; + } + + visible: show + width: 22 + height: 36 + + RowLayout { + anchors.fill: parent + spacing: 4 + visible: show + Item { + height: 22 + width: 22 + Text { + text: batteryIcon() + font.family: "Material Symbols Outlined" + font.pixelSize: 14 + color: charging ? Theme.accentPrimary : Theme.textPrimary + verticalAlignment: Text.AlignVCenter + anchors.centerIn: parent + } + MouseArea { + id: batteryMouseArea + anchors.fill: parent + hoverEnabled: true + onEntered: batteryWidget.containsMouse = true + onExited: batteryWidget.containsMouse = false + cursorShape: Qt.PointingHandCursor + } + } + } + + property bool containsMouse: false + + StyledTooltip { + id: batteryTooltip + text: { + let lines = []; + if (isReady) { + lines.push(charging ? "Charging" : "Discharging"); + lines.push(Math.round(percent) + "%"); + if (battery.changeRate !== undefined) + lines.push("Rate: " + battery.changeRate.toFixed(2) + " W"); + if (battery.timeToEmpty > 0) + lines.push("Time left: " + Math.floor(battery.timeToEmpty / 60) + " min"); + if (battery.timeToFull > 0) + lines.push("Time to full: " + Math.floor(battery.timeToFull / 60) + " min"); + if (battery.healthPercentage !== undefined) + lines.push("Health: " + Math.round(battery.healthPercentage) + "%"); + } + return lines.join("\n"); + } + tooltipVisible: batteryWidget.containsMouse + targetItem: batteryWidget + delay: 200 + } +} \ No newline at end of file diff --git a/Bar/Modules/Calendar.qml b/Bar/Modules/Calendar.qml index d090010..b034681 100644 --- a/Bar/Modules/Calendar.qml +++ b/Bar/Modules/Calendar.qml @@ -177,7 +177,7 @@ PanelWithOverlay { id: holidayTooltip text: "" tooltipVisible: false - targetItem: undefined + targetItem: null delay: 100 } } diff --git a/Bar/Modules/Volume.qml b/Bar/Modules/Volume.qml index 4df0e1f..f66f81d 100644 --- a/Bar/Modules/Volume.qml +++ b/Bar/Modules/Volume.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import qs.Settings import qs.Components +import qs.Bar.Modules Item { id: volumeDisplay @@ -24,10 +25,23 @@ Item { StyledTooltip { id: volumeTooltip text: "Volume: " + volume + "%\nScroll up/down to change volume" - tooltipVisible: false + tooltipVisible: !ioSelector.visible && volumeDisplay.containsMouse targetItem: pillIndicator delay: 200 } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (ioSelector.visible) { + ioSelector.dismiss(); + } else { + ioSelector.show(); + } + } + } } Connections { @@ -54,8 +68,8 @@ Item { hoverEnabled: true acceptedButtons: Qt.NoButton // Accept wheel events only propagateComposedEvents: true - onEntered: volumeTooltip.tooltipVisible = true - onExited: volumeTooltip.tooltipVisible = false + onEntered: volumeDisplay.containsMouse = true + onExited: volumeDisplay.containsMouse = false cursorShape: Qt.PointingHandCursor onWheel:(wheel) => { if (!shell) return; @@ -67,4 +81,11 @@ Item { } } } + + AudioDeviceSelector { + id: ioSelector + onClosed: ioSelector.dismiss() + } + + property bool containsMouse: false }