Add Audio Input/Output selector, add (probably) working battery indicator

This commit is contained in:
ly-sec 2025-07-20 09:25:58 +02:00
parent ba93df4a1f
commit f75ff03281
5 changed files with 394 additions and 4 deletions

View file

@ -87,6 +87,11 @@ Scope {
anchors.verticalCenter: parent.verticalCenter
}
Battery {
id: widgetsBattery
anchors.verticalCenter: parent.verticalCenter
}
Brightness {
id: widgetsBrightness
anchors.verticalCenter: parent.verticalCenter

View file

@ -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) {
}
}
}

81
Bar/Modules/Battery.qml Normal file
View file

@ -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
}
}

View file

@ -177,7 +177,7 @@ PanelWithOverlay {
id: holidayTooltip
text: ""
tooltipVisible: false
targetItem: undefined
targetItem: null
delay: 100
}
}

View file

@ -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
}