| 1 | import QtQuick |
| 2 | import Quickshell |
| 3 | import Quickshell.Wayland |
| 4 | import Quickshell.Bluetooth |
| 5 | import Quickshell.Hyprland |
| 6 | import Quickshell.Services.Pipewire |
| 7 | import Quickshell.Services.UPower |
| 8 | |
| 9 | // The bar: a floating pill at the top of every monitor. |
| 10 | // Left: the mark (a dotted ring: click = runner; its centre turns the live colour while something records or listens, |
| 11 | // the ring lights up in a wave on a new notification), this monitor's workspaces as dots, the cat. |
| 12 | // Centre: the active window (focused monitor only). Right: tray, recording, privacy, caffeine/airplane, headset, |
| 13 | // volume, net + bt, battery, clock (click = calendar). Clicks open the same panel tabs as always. |
| 14 | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), bar/Bar.qml. |
| 15 | PanelWindow { |
| 16 | id: bar |
| 17 | required property var modelData |
| 18 | screen: modelData |
| 19 | signal togglePanel(string name, var scr) |
| 20 | signal toggleOverlay(string name, var scr) |
| 21 | |
| 22 | anchors { top: true; left: true; right: true } |
| 23 | implicitHeight: Theme.barHeight // the pill + the gap above it; also the exclusive zone |
| 24 | color: "transparent" |
| 25 | mask: Region { item: pill } |
| 26 | WlrLayershell.namespace: "aurora-bar" |
| 27 | |
| 28 | SystemClock { id: clock; precision: SystemClock.Minutes } |
| 29 | |
| 30 | // caffeine: one inhibitor is enough, keep it on the first screen's bar |
| 31 | IdleInhibitor { |
| 32 | window: bar |
| 33 | enabled: Toggles.caffeine && bar.modelData === Quickshell.screens[0] |
| 34 | } |
| 35 | |
| 36 | // ---- state ---- |
| 37 | readonly property var audio: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.audio : null |
| 38 | readonly property var mic: Pipewire.defaultAudioSource ? Pipewire.defaultAudioSource.audio : null |
| 39 | readonly property var bat: UPower.displayDevice |
| 40 | readonly property bool hasBat: bat !== null && bat.isPresent |
| 41 | readonly property real batPct: hasBat ? (bat.percentage > 1 ? bat.percentage / 100 : bat.percentage) : 0 |
| 42 | readonly property bool charging: hasBat && bat.state === UPowerDeviceState.Charging |
| 43 | readonly property bool batLow: hasBat && batPct <= Settings.batLow / 100 && !charging |
| 44 | readonly property var adapter: Bluetooth.defaultAdapter |
| 45 | |
| 46 | // which Hyprland monitor this bar belongs to, and whether it has focus |
| 47 | readonly property var hmon: Hyprland.monitorFor(modelData) |
| 48 | readonly property bool monFocused: hmon !== null && hmon.focused |
| 49 | |
| 50 | function findHeadset() { |
| 51 | var l = Bluetooth.devices.values |
| 52 | for (var i = 0; i < l.length; i++) { |
| 53 | var d = l[i] |
| 54 | if (!d.connected || !d.batteryAvailable) continue |
| 55 | var ic = String(d.icon) |
| 56 | if (ic.indexOf("head") >= 0 || ic.indexOf("audio") >= 0) return d |
| 57 | } |
| 58 | return null |
| 59 | } |
| 60 | function btConnected() { |
| 61 | var l = Bluetooth.devices.values |
| 62 | for (var i = 0; i < l.length; i++) if (l[i].connected) return true |
| 63 | return false |
| 64 | } |
| 65 | readonly property var headset: findHeadset() |
| 66 | readonly property bool btOn: adapter !== null && adapter.enabled |
| 67 | readonly property bool btLink: btOn && btConnected() |
| 68 | // something records or listens: the mark's centre goes live |
| 69 | readonly property bool live: Rec.busy || Privacy.mic || Privacy.cam || Privacy.screen |
| 70 | |
| 71 | // a new toast: the mark's ring lights up for 3s (error colour if critical) |
| 72 | property bool flash: false |
| 73 | property bool flashCrit: false |
| 74 | Timer { id: flashT; interval: 3000; onTriggered: bar.flash = false } |
| 75 | Connections { |
| 76 | target: Notifs |
| 77 | function onArrived(n, critical) { bar.flashCrit = critical; bar.flash = true; flashT.restart() } |
| 78 | } |
| 79 | |
| 80 | Rectangle { |
| 81 | id: pill |
| 82 | x: Theme.barGap |
| 83 | y: Theme.barGap |
| 84 | width: parent.width - 2 * Theme.barGap |
| 85 | height: Theme.barSize |
| 86 | radius: height / 2 |
| 87 | color: Qt.rgba(Theme.s1.r, Theme.s1.g, Theme.s1.b, Theme.barAlpha) |
| 88 | border.width: 1 |
| 89 | border.color: Theme.s2 |
| 90 | |
| 91 | // ---------------- left ---------------- |
| 92 | Row { |
| 93 | anchors.left: parent.left |
| 94 | anchors.leftMargin: Math.round(Theme.barSize * 0.25) |
| 95 | height: parent.height |
| 96 | spacing: 14 |
| 97 | |
| 98 | Item { |
| 99 | id: mark |
| 100 | readonly property real size: Math.round(Theme.barSize * 0.55) |
| 101 | width: size |
| 102 | height: size |
| 103 | anchors.verticalCenter: parent.verticalCenter |
| 104 | Ring { |
| 105 | anchors.fill: parent |
| 106 | anchors.margins: 1.5 |
| 107 | n: 16 |
| 108 | dot: 2.6 |
| 109 | on: bar.flash ? (bar.flashCrit ? Theme.err : Theme.fill) : Theme.fg |
| 110 | off: Theme.s3 |
| 111 | } |
| 112 | Rectangle { |
| 113 | anchors.centerIn: parent |
| 114 | width: Math.round(mark.size * 0.32) |
| 115 | height: width |
| 116 | radius: width / 2 |
| 117 | color: bar.live ? Theme.live : Theme.fg |
| 118 | } |
| 119 | MouseArea { |
| 120 | anchors.fill: parent |
| 121 | anchors.margins: -5 |
| 122 | cursorShape: Qt.PointingHandCursor |
| 123 | onClicked: bar.toggleOverlay("runner", bar.modelData) |
| 124 | } |
| 125 | } |
| 126 | Workspaces { height: parent.height; monitor: bar.hmon } |
| 127 | CatLoop { height: parent.height } |
| 128 | } |
| 129 | |
| 130 | // ---------------- center ---------------- |
| 131 | Lb { |
| 132 | anchors.centerIn: parent |
| 133 | width: Math.min(implicitWidth, parent.width * 0.32) |
| 134 | horizontalAlignment: Text.AlignHCenter |
| 135 | elide: Text.ElideRight |
| 136 | readonly property var active: ToplevelManager.activeToplevel |
| 137 | readonly property string app: active && active.appId ? String(active.appId).split(".").pop() : "" |
| 138 | visible: bar.monFocused && active !== null && active.title !== "" // the active window is global: focused monitor only |
| 139 | text: active ? (app !== "" ? app + " · " + active.title : active.title) : "" |
| 140 | px: 11 |
| 141 | color: Theme.fgDim |
| 142 | } |
| 143 | |
| 144 | // ---------------- right ---------------- |
| 145 | Row { |
| 146 | anchors.right: parent.right |
| 147 | anchors.rightMargin: 6 |
| 148 | height: parent.height |
| 149 | spacing: 0 |
| 150 | |
| 151 | Tray { show: bar.monFocused } |
| 152 | |
| 153 | BarPill { // click to stop recording / cancel the area selection |
| 154 | visible: Rec.busy |
| 155 | icon: Theme.iStop |
| 156 | text: Rec.running ? "rec " + Theme.fmtClock(Rec.elapsed) : "select area" |
| 157 | tint: Theme.live |
| 158 | onActivated: Rec.stop() |
| 159 | } |
| 160 | |
| 161 | // privacy: global, so only on the focused monitor's bar. Click the mic one to mute the mic. |
| 162 | BarPill { |
| 163 | visible: bar.monFocused && Privacy.mic |
| 164 | icon: bar.mic && bar.mic.muted ? Theme.iMicOff : Theme.iMic |
| 165 | text: Privacy.micApps.join(", ") |
| 166 | tint: bar.mic && bar.mic.muted ? Theme.grey : Theme.live |
| 167 | onActivated: if (bar.mic) bar.mic.muted = !bar.mic.muted |
| 168 | } |
| 169 | BarPill { |
| 170 | visible: bar.monFocused && Privacy.cam |
| 171 | icon: Theme.iWebcam |
| 172 | text: Privacy.camApps.join(", ") |
| 173 | tint: Theme.live |
| 174 | } |
| 175 | BarPill { |
| 176 | visible: bar.monFocused && Privacy.screen |
| 177 | icon: Theme.iCast |
| 178 | text: Privacy.shareApps.join(", ") |
| 179 | tint: Theme.live |
| 180 | } |
| 181 | |
| 182 | BarPill { |
| 183 | visible: Toggles.caffeine || Toggles.airplane |
| 184 | icon: (Toggles.caffeine ? Theme.iCoffee : "") + (Toggles.caffeine && Toggles.airplane ? " " : "") |
| 185 | + (Toggles.airplane ? Theme.iPlane : "") |
| 186 | onActivated: bar.togglePanel("home", bar.modelData) |
| 187 | } |
| 188 | |
| 189 | BarPill { |
| 190 | visible: bar.headset !== null |
| 191 | icon: Theme.iHeadphones |
| 192 | text: bar.headset ? Math.round(bar.headset.battery * 100) + "%" : "" |
| 193 | onActivated: bar.togglePanel("bt", bar.modelData) |
| 194 | } |
| 195 | |
| 196 | BarPill { |
| 197 | visible: bar.audio !== null |
| 198 | icon: Theme.volIcon(bar.audio ? bar.audio.volume : 0, bar.audio ? bar.audio.muted : true) |
| 199 | text: (bar.audio ? Math.round(bar.audio.volume * 100) : 0) + "%" |
| 200 | tint: bar.audio && bar.audio.muted ? Theme.grey : Theme.fg |
| 201 | icon2: bar.mic && bar.mic.muted ? Theme.iMicOff : "" |
| 202 | tint2: Theme.grey |
| 203 | onActivated: button => { |
| 204 | if (button === Qt.RightButton && bar.audio) bar.audio.muted = !bar.audio.muted |
| 205 | else if (button === Qt.MiddleButton && bar.mic) bar.mic.muted = !bar.mic.muted |
| 206 | else bar.togglePanel("audio", bar.modelData) |
| 207 | } |
| 208 | onActivated2: button => { if (bar.mic) bar.mic.muted = !bar.mic.muted } |
| 209 | onScrolled: dir => { |
| 210 | if (bar.audio) bar.audio.volume = Math.max(0, Math.min(1, bar.audio.volume + dir * 0.02)) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // net + bt share one block: click the left icon for the net tab, the right one for bt |
| 215 | BarPill { |
| 216 | icon: Net.wired && !(Net.wifiEnabled && Net.connected !== "") ? Net.wiredIcon |
| 217 | : (Net.wifiEnabled ? Theme.wifiIcon(Net.connected !== "" ? Net.activeSignal : 0) : Theme.iWifiOff) |
| 218 | tint: Net.portal ? Theme.live : ((Net.wired || (Net.wifiEnabled && Net.connected !== "")) ? Theme.fg : Theme.grey) |
| 219 | icon2: bar.adapter === null ? "" : (!bar.btOn ? Theme.iBtOff : (bar.btLink ? Theme.iBtConn : Theme.iBt)) |
| 220 | tint2: bar.btLink ? Theme.fg : Theme.grey |
| 221 | onActivated: bar.togglePanel("net", bar.modelData) |
| 222 | onActivated2: bar.togglePanel("bt", bar.modelData) |
| 223 | } |
| 224 | |
| 225 | // battery: a capsule that fills up, the error colour when low, a bolt + breathing fill while charging |
| 226 | BarPill { |
| 227 | id: batt |
| 228 | visible: bar.hasBat |
| 229 | icon: bar.charging ? Theme.iBolt : "" |
| 230 | onActivated: bar.togglePanel("sys", bar.modelData) |
| 231 | // breathing at 20 steps a second: an Animation would redraw the bar on every vsync while charging |
| 232 | property real phase: 0 |
| 233 | readonly property real pulse: bar.charging ? 0.675 + 0.325 * Math.cos(phase * Math.PI / 1.1) : 1 |
| 234 | Timer { |
| 235 | running: bar.charging && bar.visible |
| 236 | interval: 50 |
| 237 | repeat: true |
| 238 | onTriggered: batt.phase = (batt.phase + 0.05) % 2.2 |
| 239 | onRunningChanged: batt.phase = 0 |
| 240 | } |
| 241 | Rectangle { |
| 242 | anchors.verticalCenter: parent.verticalCenter |
| 243 | width: 26 |
| 244 | height: 13 |
| 245 | radius: height / 2 |
| 246 | color: "transparent" |
| 247 | border.width: 1.5 |
| 248 | border.color: bar.batLow ? Theme.err : Theme.grey |
| 249 | Rectangle { |
| 250 | x: 3 |
| 251 | anchors.verticalCenter: parent.verticalCenter |
| 252 | width: Math.max(3, (parent.width - 6) * bar.batPct) |
| 253 | height: parent.height - 6 |
| 254 | radius: height / 2 |
| 255 | color: bar.batLow ? Theme.err : Theme.fill |
| 256 | opacity: batt.pulse |
| 257 | } |
| 258 | } |
| 259 | Lb { |
| 260 | anchors.verticalCenter: parent.verticalCenter |
| 261 | text: Math.round(bar.batPct * 100) |
| 262 | px: 11 |
| 263 | color: bar.batLow ? Theme.err : Theme.fg |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // clock: Doto from bar size 40 up (Doto is unreadable smaller), Space Mono below that |
| 268 | BarPill { |
| 269 | onActivated: bar.toggleOverlay("cal", bar.modelData) |
| 270 | Num { |
| 271 | visible: Theme.barSize >= 40 |
| 272 | anchors.verticalCenter: parent.verticalCenter |
| 273 | text: Theme.hh(clock.date) + ":" + Qt.formatDateTime(clock.date, "mm") |
| 274 | font.pixelSize: 26 |
| 275 | } |
| 276 | Text { |
| 277 | visible: Theme.barSize < 40 |
| 278 | anchors.verticalCenter: parent.verticalCenter |
| 279 | text: Theme.hh(clock.date) + ":" + Qt.formatDateTime(clock.date, "mm") |
| 280 | font.family: Theme.mono |
| 281 | font.pixelSize: 14 |
| 282 | font.bold: true |
| 283 | color: Theme.fg |
| 284 | } |
| 285 | Lb { |
| 286 | anchors.verticalCenter: parent.verticalCenter |
| 287 | text: Qt.locale("en_US").toString(clock.date, "ddd d") |
| 288 | px: 10 |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | } |