import QtQuick 2.15 import Quickshell import Quickshell.Io import Quickshell.Services.SystemTray import Quickshell.Services.Pipewire import Quickshell.Services.UPower PanelWindow { id: root property var niriWindowsList: [] property var niriWorkspacesList: [] function px(val) { return val / screen.devicePixelRatio } visible: true implicitHeight: px(60) exclusiveZone: implicitHeight exclusionMode: ExclusionMode.Auto implicitWidth: screen.width color: Config.color("background") anchors { top: true } property bool idleInhibitActive: false Process { running: root.idleInhibitActive command: ["systemd-inhibit", "--what=idle:sleep", "--who=quickshell-bar", "--why=Idle inhibitor toggled on", "--mode=block", "sleep", "infinity"] } property var activeTrayMenu: null property int trayMenuAnchorX: 0 property var activeSubmenuEntry: null TrayMenuPopup { id: trayMenuPopup bar: root } TraySubmenuPopup { bar: root mainPopup: trayMenuPopup } AudioPopup { id: audioPopup bar: root } // single clock tick shared by any clock widget instance property string clockText: Qt.formatDateTime(new Date(), "h:mm AP") Timer { interval: 1000 running: true repeat: true onTriggered: root.clockText = Qt.formatDateTime(new Date(), "h:mm AP") } // name -> widget component, used by the section Repeaters below function widgetFor(name) { switch (name) { case "niriWindows": return niriWindowsComp case "idleInhibit": return idleInhibitComp case "audio": return audioComp case "battery": return batteryComp case "wattage": return wattageComp case "tray": return trayComp case "clock": return clockComp default: return null } } // --- widgets --------------------------------------------------------------- // Each widget defines its own implicit size and centers its content, so the // hosting Loader in a section Row sizes to it automatically. Component { id: niriWindowsComp NiriWindowList { niriWindowsList: root.niriWindowsList niriWorkspacesList: root.niriWorkspacesList outputName: root.screen.name barColor: root.color } } Component { id: idleInhibitComp Item { implicitWidth: 28 implicitHeight: 26 Rectangle { anchors.centerIn: parent width: 26 height: 26 radius: 6 color: root.idleInhibitActive ? Config.color("accentBackground") : (idleInhibitArea.containsMouse ? Config.color("surface") : Config.color("transparent")) border.color: root.idleInhibitActive ? Config.color("accent") : (idleInhibitArea.containsMouse ? Config.color("focus") : Config.color("transparent")) border.width: 1 Text { anchors.centerIn: parent text: "󰅶" font.family: "Maple Mono" font.pixelSize: 16 color: root.idleInhibitActive ? Config.color("accent") : Config.color("muted") } } MouseArea { id: idleInhibitArea anchors.fill: parent hoverEnabled: true onClicked: root.idleInhibitActive = !root.idleInhibitActive } } } Component { id: audioComp Item { id: audio implicitWidth: audioContent.width + 8 implicitHeight: 26 property bool muted: Pipewire.defaultAudioSink?.audio.muted ?? false Row { id: audioContent anchors.centerIn: parent spacing: 4 Text { anchors.verticalCenter: parent.verticalCenter text: audio.muted ? String.fromCodePoint(0xF04C4) : "󰓃" font.family: "Maple Mono" font.pixelSize: 16 color: audio.muted ? Config.color("muted") : Config.color("foreground") } Text { anchors.verticalCenter: parent.verticalCenter text: Math.round((Pipewire.defaultAudioSink?.audio.volume ?? 0) * 100) + "%" font.family: "Inter" font.pixelSize: 13 font.weight: 700 color: audio.muted ? Config.color("muted") : Config.color("foreground") } } MouseArea { anchors.fill: parent onWheel: (wheel) => { if (!Pipewire.defaultAudioSink) return var delta = wheel.angleDelta.y > 0 ? 0.01 : -0.01 Pipewire.defaultAudioSink.audio.volume = Math.max(0, Math.min(1, Pipewire.defaultAudioSink.audio.volume + delta)) } onClicked: audioPopup.visible = !audioPopup.visible } } } Component { id: trayComp Row { spacing: root.px(4) Repeater { model: SystemTray.items Item { id: trayItem required property SystemTrayItem modelData implicitWidth: 24 implicitHeight: 26 Image { anchors.centerIn: parent width: 16 height: 16 source: trayItem.modelData.icon sourceSize: Qt.size(16, 16) smooth: true } MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: function(event) { if (event.button === Qt.LeftButton) { if (trayItem.modelData.hasMenu) { if (trayMenuPopup.visible && root.activeTrayMenu === trayItem.modelData.menu) { trayMenuPopup.visible = false } else { root.trayMenuAnchorX = trayItem.mapToItem(null, 0, 0).x root.activeTrayMenu = trayItem.modelData.menu trayMenuPopup.visible = true } } else { trayItem.modelData.activate() } } } } } } } } Component { id: clockComp Item { // Size to the text so the clock sits flush against the right margin // (a fixed width + left-aligned text left dead space to the right edge). implicitWidth: clockLabel.width implicitHeight: 26 Text { id: clockLabel anchors.centerIn: parent color: Config.color("foreground") font.family: "Inter" font.pixelSize: 16 font.weight: 700 text: root.clockText } } } Component { id: batteryComp Item { id: bat property var dev: UPower.displayDevice property bool present: dev && dev.isPresent // Quickshell reports percentage as a 0.0-1.0 fraction; scale to 0-100. property real pct: dev ? dev.percentage * 100 : 0 property int st: dev ? dev.state : 0 property bool charging: st === UPowerDeviceState.Charging || st === UPowerDeviceState.PendingCharge property bool full: st === UPowerDeviceState.FullyCharged property bool low: pct <= 15 && !charging && !full // Material Design (Nerd Font) battery ramp, reactive on pct/state. readonly property string glyph: { var p = Math.max(0, Math.min(100, pct)) if (full) return String.fromCodePoint(0xF0079) if (charging) { var c = [0xF089C, 0xF0086, 0xF0087, 0xF0088, 0xF089D, 0xF0089, 0xF089E, 0xF008A, 0xF008B, 0xF0085] return String.fromCodePoint(c[Math.min(9, Math.floor(p / 10))]) } if (p >= 95) return String.fromCodePoint(0xF0079) var d = [0xF007A, 0xF007B, 0xF007C, 0xF007D, 0xF007E, 0xF007F, 0xF0080, 0xF0081, 0xF0082] return String.fromCodePoint(d[Math.max(0, Math.min(8, Math.floor(p / 10) - 1))]) } visible: present implicitWidth: present ? batContent.width + 8 : 0 implicitHeight: 26 Row { id: batContent anchors.centerIn: parent spacing: 4 Text { anchors.verticalCenter: parent.verticalCenter text: bat.glyph font.family: "Maple Mono" font.pixelSize: 16 color: bat.low ? Config.color("negative") : (bat.charging || bat.full ? Config.color("positive") : Config.color("foreground")) } Text { anchors.verticalCenter: parent.verticalCenter text: Math.round(bat.pct) + "%" font.family: "Inter" font.pixelSize: 13 font.weight: 700 color: bat.low ? Config.color("negative") : Config.color("foreground") } } } } Component { id: wattageComp Item { id: watt property var dev: UPower.displayDevice property bool present: dev && dev.isPresent property real rate: dev ? dev.changeRate : 0 property int st: dev ? dev.state : 0 property bool charging: st === UPowerDeviceState.Charging || st === UPowerDeviceState.PendingCharge property bool discharging: st === UPowerDeviceState.Discharging || st === UPowerDeviceState.PendingDischarge // Show whenever the battery is actively charging or discharging, regardless of // the instantaneous rate. The rate briefly reads 0 (e.g. just after (un)plugging, // or when the EC returns an empty current_now), which would otherwise flicker the // widget out during discharge. property bool active: present && (charging || discharging) visible: active implicitWidth: active ? wattContent.width + 8 : 0 implicitHeight: 26 Row { id: wattContent anchors.centerIn: parent spacing: 4 Text { anchors.verticalCenter: parent.verticalCenter text: String.fromCodePoint(0xF140B) // lightning bolt font.family: "Maple Mono" font.pixelSize: 16 color: watt.charging ? Config.color("positive") : Config.color("foreground") } Text { anchors.verticalCenter: parent.verticalCenter text: (watt.charging ? "+" : "-") + watt.rate.toFixed(1) + " W" font.family: "Inter" font.pixelSize: 13 font.weight: 700 color: Config.color("foreground") } } } } // --- sections -------------------------------------------------------------- // Order and placement come from Config; reordering is a config edit. component WidgetRow: Row { id: widgetRow property var widgets: [] height: parent.height spacing: px(4) Repeater { model: widgetRow.widgets Loader { required property var modelData anchors.verticalCenter: widgetRow.verticalCenter sourceComponent: root.widgetFor(modelData) } } } // Left section is clipped to the space before the right cluster so a long window // list truncates instead of overlapping the right-hand widgets. Item { id: leftContainer clip: true height: parent.height anchors { left: parent.left leftMargin: px(10) right: rightRow.left rightMargin: px(8) verticalCenter: parent.verticalCenter } WidgetRow { widgets: Config.leftWidgets anchors { left: parent.left verticalCenter: parent.verticalCenter } } } WidgetRow { widgets: Config.centerWidgets anchors { horizontalCenter: parent.horizontalCenter verticalCenter: parent.verticalCenter } } WidgetRow { id: rightRow widgets: Config.rightWidgets anchors { right: parent.right rightMargin: px(10) verticalCenter: parent.verticalCenter } } }